diff --git a/.vscode/settings.json b/.vscode/settings.json index 9771a27a0de3e..2f1443066768d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,8 @@ { "cSpell.words": [ + "afero", "apps", + "ASKPASS", "awsidentity", "bodyclose", "buildinfo", @@ -19,16 +21,20 @@ "derphttp", "derpmap", "devel", + "devtunnel", "dflags", "drpc", "drpcconn", "drpcmux", "drpcserver", "Dsts", + "embeddedpostgres", "enablements", + "errgroup", "eventsourcemock", "fatih", "Formik", + "gitauth", "gitsshkey", "goarch", "gographviz", @@ -78,6 +84,7 @@ "parameterscopeid", "pqtype", "prometheusmetrics", + "promhttp", "promptui", "protobuf", "provisionerd", diff --git a/agent/agent.go b/agent/agent.go index 355f5c0800f79..66e009a207e5c 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -26,6 +26,7 @@ import ( "github.com/gliderlabs/ssh" "github.com/google/uuid" "github.com/pkg/sftp" + "github.com/spf13/afero" "go.uber.org/atomic" gossh "golang.org/x/crypto/ssh" "golang.org/x/xerrors" @@ -35,6 +36,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/agent/usershell" "github.com/coder/coder/buildinfo" + "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/codersdk" "github.com/coder/coder/pty" "github.com/coder/coder/tailnet" @@ -53,6 +55,7 @@ const ( ) type Options struct { + Filesystem afero.Fs ExchangeToken func(ctx context.Context) error Client Client ReconnectingPTYTimeout time.Duration @@ -72,6 +75,9 @@ func New(options Options) io.Closer { if options.ReconnectingPTYTimeout == 0 { options.ReconnectingPTYTimeout = 5 * time.Minute } + if options.Filesystem == nil { + options.Filesystem = afero.NewOsFs() + } ctx, cancelFunc := context.WithCancel(context.Background()) server := &agent{ reconnectingPTYTimeout: options.ReconnectingPTYTimeout, @@ -81,6 +87,7 @@ func New(options Options) io.Closer { envVars: options.EnvironmentVariables, client: options.Client, exchangeToken: options.ExchangeToken, + filesystem: options.Filesystem, stats: &Stats{}, } server.init(ctx) @@ -91,6 +98,7 @@ type agent struct { logger slog.Logger client Client exchangeToken func(ctx context.Context) error + filesystem afero.Fs reconnectingPTYs sync.Map reconnectingPTYTimeout time.Duration @@ -171,6 +179,13 @@ func (a *agent) run(ctx context.Context) error { }() } + if metadata.GitAuthConfigs > 0 { + err = gitauth.OverrideVSCodeConfigs(a.filesystem) + if err != nil { + return xerrors.Errorf("override vscode configuration for git auth: %w", err) + } + } + // This automatically closes when the context ends! appReporterCtx, appReporterCtxCancel := context.WithCancel(ctx) defer appReporterCtxCancel() diff --git a/agent/agent_test.go b/agent/agent_test.go index b392d6cefa5cd..805a2bc119d2d 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -27,6 +27,7 @@ import ( "github.com/google/uuid" "github.com/pion/udp" "github.com/pkg/sftp" + "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" @@ -543,6 +544,38 @@ func TestAgent(t *testing.T) { return initialized.Load() == 2 }, testutil.WaitShort, testutil.IntervalFast) }) + + t.Run("WriteVSCodeConfigs", func(t *testing.T) { + t.Parallel() + client := &client{ + t: t, + agentID: uuid.New(), + metadata: codersdk.WorkspaceAgentMetadata{ + GitAuthConfigs: 1, + }, + statsChan: make(chan *codersdk.AgentStats), + coordinator: tailnet.NewCoordinator(), + } + filesystem := afero.NewMemMapFs() + closer := agent.New(agent.Options{ + ExchangeToken: func(ctx context.Context) error { + return nil + }, + Client: client, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelInfo), + Filesystem: filesystem, + }) + t.Cleanup(func() { + _ = closer.Close() + }) + home, err := os.UserHomeDir() + require.NoError(t, err) + path := filepath.Join(home, ".vscode-server", "data", "Machine", "settings.json") + require.Eventually(t, func() bool { + _, err := filesystem.Stat(path) + return err == nil + }, testutil.WaitShort, testutil.IntervalFast) + }) } func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd { diff --git a/cli/agent.go b/cli/agent.go index 088f7c2019845..62596344ebda4 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -169,8 +169,9 @@ func workspaceAgent() *cobra.Command { }, EnvironmentVariables: map[string]string{ // Override the "CODER_AGENT_TOKEN" variable in all - // shells so "gitssh" works! + // shells so "gitssh" and "gitaskpass" works! "CODER_AGENT_TOKEN": client.SessionToken, + "GIT_ASKPASS": executablePath, }, }) <-cmd.Context().Done() diff --git a/cli/config/server.yaml b/cli/config/server.yaml new file mode 100644 index 0000000000000..8dcee73a0b12f --- /dev/null +++ b/cli/config/server.yaml @@ -0,0 +1,25 @@ +# Coder Server Configuration + +# Automatically authenticate HTTP(s) Git requests. +gitauth: +# Supported: azure-devops, bitbucket, github, gitlab +# - type: github +# client_id: xxxxxx +# client_secret: xxxxxx + +# Multiple providers are an Enterprise feature. +# Contact sales@coder.com for a license. +# +# If multiple providers are used, a unique "id" +# must be provided for each one. +# - id: example +# type: azure-devops +# client_id: xxxxxxx +# client_secret: xxxxxxx +# A custom regex can be used to match a specific +# repository or organization to limit auth scope. +# regex: github.com/coder +# Custom authentication and token URLs should be +# used for self-managed Git provider deployments. +# auth_url: https://example.com/oauth/authorize +# token_url: https://example.com/oauth/token diff --git a/cli/deployment/config.go b/cli/deployment/config.go index 6f606f6d3d023..e96a64746ff8b 100644 --- a/cli/deployment/config.go +++ b/cli/deployment/config.go @@ -97,6 +97,12 @@ func newConfig() *codersdk.DeploymentConfig { }, }, }, + GitAuth: &codersdk.DeploymentConfigField[[]codersdk.GitAuthConfig]{ + Name: "Git Auth", + Usage: "Automatically authenticate Git inside workspaces.", + Flag: "gitauth", + Default: []codersdk.GitAuthConfig{}, + }, Prometheus: &codersdk.PrometheusConfig{ Enable: &codersdk.DeploymentConfigField[bool]{ Name: "Prometheus Enable", @@ -407,6 +413,9 @@ func setConfig(prefix string, vip *viper.Viper, target interface{}) { value = append(value, strings.Split(entry, ",")...) } val.FieldByName("Value").Set(reflect.ValueOf(value)) + case []codersdk.GitAuthConfig: + values := readSliceFromViper[codersdk.GitAuthConfig](vip, prefix, value) + val.FieldByName("Value").Set(reflect.ValueOf(values)) default: panic(fmt.Sprintf("unsupported type %T", value)) } @@ -437,6 +446,44 @@ func setConfig(prefix string, vip *viper.Viper, target interface{}) { } } +// readSliceFromViper reads a typed mapping from the key provided. +// This enables environment variables like CODER_GITAUTH__CLIENT_ID. +func readSliceFromViper[T any](vip *viper.Viper, key string, value any) []T { + elementType := reflect.TypeOf(value).Elem() + returnValues := make([]T, 0) + for entry := 0; true; entry++ { + // Only create an instance when the entry exists in viper... + // otherwise we risk + var instance *reflect.Value + for i := 0; i < elementType.NumField(); i++ { + fve := elementType.Field(i) + prop := fve.Tag.Get("json") + // For fields that are omitted in JSON, we use a YAML tag. + if prop == "-" { + prop = fve.Tag.Get("yaml") + } + value := vip.Get(fmt.Sprintf("%s.%d.%s", key, entry, prop)) + if value == nil { + continue + } + if instance == nil { + newType := reflect.Indirect(reflect.New(elementType)) + instance = &newType + } + instance.Field(i).Set(reflect.ValueOf(value)) + } + if instance == nil { + break + } + value, ok := instance.Interface().(T) + if !ok { + continue + } + returnValues = append(returnValues, value) + } + return returnValues +} + func NewViper() *viper.Viper { dc := newConfig() vip := viper.New() @@ -516,6 +563,8 @@ func setFlags(prefix string, flagset *pflag.FlagSet, vip *viper.Viper, target in _ = flagset.DurationP(flg, shorthand, vip.GetDuration(prefix), usage) case []string: _ = flagset.StringSliceP(flg, shorthand, vip.GetStringSlice(prefix), usage) + case []codersdk.GitAuthConfig: + // Ignore this one! default: panic(fmt.Sprintf("unsupported type %T", typ)) } diff --git a/cli/deployment/config_test.go b/cli/deployment/config_test.go index aeb50d4914b5a..73a138d1932ce 100644 --- a/cli/deployment/config_test.go +++ b/cli/deployment/config_test.go @@ -148,6 +148,45 @@ func TestConfig(t *testing.T) { require.Equal(t, []string{"coder"}, config.OAuth2.Github.AllowedTeams.Value) require.Equal(t, config.OAuth2.Github.AllowSignups.Value, true) }, + }, { + Name: "GitAuth", + Env: map[string]string{ + "CODER_GITAUTH_0_ID": "hello", + "CODER_GITAUTH_0_TYPE": "github", + "CODER_GITAUTH_0_CLIENT_ID": "client", + "CODER_GITAUTH_0_CLIENT_SECRET": "secret", + "CODER_GITAUTH_0_AUTH_URL": "https://auth.com", + "CODER_GITAUTH_0_TOKEN_URL": "https://token.com", + "CODER_GITAUTH_0_REGEX": "github.com", + + "CODER_GITAUTH_1_ID": "another", + "CODER_GITAUTH_1_TYPE": "gitlab", + "CODER_GITAUTH_1_CLIENT_ID": "client-2", + "CODER_GITAUTH_1_CLIENT_SECRET": "secret-2", + "CODER_GITAUTH_1_AUTH_URL": "https://auth-2.com", + "CODER_GITAUTH_1_TOKEN_URL": "https://token-2.com", + "CODER_GITAUTH_1_REGEX": "gitlab.com", + }, + Valid: func(config *codersdk.DeploymentConfig) { + require.Len(t, config.GitAuth.Value, 2) + require.Equal(t, []codersdk.GitAuthConfig{{ + ID: "hello", + Type: "github", + ClientID: "client", + ClientSecret: "secret", + AuthURL: "https://auth.com", + TokenURL: "https://token.com", + Regex: "github.com", + }, { + ID: "another", + Type: "gitlab", + ClientID: "client-2", + ClientSecret: "secret-2", + AuthURL: "https://auth-2.com", + TokenURL: "https://token-2.com", + Regex: "gitlab.com", + }}, config.GitAuth.Value) + }, }} { tc := tc t.Run(tc.Name, func(t *testing.T) { diff --git a/cli/gitaskpass.go b/cli/gitaskpass.go new file mode 100644 index 0000000000000..52af8b31da8a9 --- /dev/null +++ b/cli/gitaskpass.go @@ -0,0 +1,83 @@ +package cli + +import ( + "errors" + "fmt" + "net/http" + "os/signal" + "time" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/gitauth" + "github.com/coder/coder/codersdk" + "github.com/coder/retry" +) + +// gitAskpass is used by the Coder agent to automatically authenticate +// with Git providers based on a hostname. +func gitAskpass() *cobra.Command { + return &cobra.Command{ + Use: "gitaskpass", + Hidden: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + ctx, stop := signal.NotifyContext(ctx, interruptSignals...) + defer stop() + + user, host, err := gitauth.ParseAskpass(args[0]) + if err != nil { + return xerrors.Errorf("parse host: %w", err) + } + + client, err := createAgentClient(cmd) + if err != nil { + return xerrors.Errorf("create agent client: %w", err) + } + + token, err := client.WorkspaceAgentGitAuth(ctx, host, false) + if err != nil { + var apiError *codersdk.Error + if errors.As(err, &apiError) && apiError.StatusCode() == http.StatusNotFound { + // This prevents the "Run 'coder --help' for usage" + // message from occurring. + cmd.Printf("%s\n", apiError.Message) + return cliui.Canceled + } + return xerrors.Errorf("get git token: %w", err) + } + if token.URL != "" { + if err := openURL(cmd, token.URL); err != nil { + cmd.Printf("Your browser has been opened to authenticate with Git:\n\n\t%s\n\n", token.URL) + } else { + cmd.Printf("Open the following URL to authenticate with Git:\n\n\t%s\n\n", token.URL) + } + + for r := retry.New(250*time.Millisecond, 10*time.Second); r.Wait(ctx); { + token, err = client.WorkspaceAgentGitAuth(ctx, host, true) + if err != nil { + continue + } + cmd.Printf("\nYou've been authenticated with Git!\n") + break + } + } + + if token.Password != "" { + if user == "" { + fmt.Fprintln(cmd.OutOrStdout(), token.Username) + } else { + fmt.Fprintln(cmd.OutOrStdout(), token.Password) + } + } else { + fmt.Fprintln(cmd.OutOrStdout(), token.Username) + } + + return nil + }, + } +} diff --git a/cli/gitaskpass_test.go b/cli/gitaskpass_test.go new file mode 100644 index 0000000000000..344f95eef52a6 --- /dev/null +++ b/cli/gitaskpass_test.go @@ -0,0 +1,97 @@ +package cli_test + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/pty/ptytest" +) + +// nolint:paralleltest +func TestGitAskpass(t *testing.T) { + t.Setenv("GIT_PREFIX", "/") + t.Run("UsernameAndPassword", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpapi.Write(context.Background(), w, http.StatusOK, codersdk.WorkspaceAgentGitAuthResponse{ + Username: "something", + Password: "bananas", + }) + })) + t.Cleanup(srv.Close) + url := srv.URL + cmd, _ := clitest.New(t, "--agent-url", url, "Username for 'https://github.com':") + pty := ptytest.New(t) + cmd.SetOutput(pty.Output()) + err := cmd.Execute() + require.NoError(t, err) + pty.ExpectMatch("something") + + cmd, _ = clitest.New(t, "--agent-url", url, "Password for 'https://potato@github.com':") + pty = ptytest.New(t) + cmd.SetOutput(pty.Output()) + err = cmd.Execute() + require.NoError(t, err) + pty.ExpectMatch("bananas") + }) + + t.Run("NoHost", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpapi.Write(context.Background(), w, http.StatusNotFound, codersdk.Response{ + Message: "Nope!", + }) + })) + t.Cleanup(srv.Close) + url := srv.URL + cmd, _ := clitest.New(t, "--agent-url", url, "--no-open", "Username for 'https://github.com':") + pty := ptytest.New(t) + cmd.SetOutput(pty.Output()) + err := cmd.Execute() + require.ErrorIs(t, err, cliui.Canceled) + pty.ExpectMatch("Nope!") + }) + + t.Run("Poll", func(t *testing.T) { + resp := atomic.Pointer[codersdk.WorkspaceAgentGitAuthResponse]{} + resp.Store(&codersdk.WorkspaceAgentGitAuthResponse{ + URL: "https://something.org", + }) + poll := make(chan struct{}, 10) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + val := resp.Load() + if r.URL.Query().Has("listen") { + poll <- struct{}{} + if val.URL != "" { + httpapi.Write(context.Background(), w, http.StatusInternalServerError, val) + return + } + } + httpapi.Write(context.Background(), w, http.StatusOK, val) + })) + t.Cleanup(srv.Close) + url := srv.URL + + cmd, _ := clitest.New(t, "--agent-url", url, "--no-open", "Username for 'https://github.com':") + pty := ptytest.New(t) + cmd.SetOutput(pty.Output()) + go func() { + err := cmd.Execute() + assert.NoError(t, err) + }() + <-poll + resp.Store(&codersdk.WorkspaceAgentGitAuthResponse{ + Username: "username", + Password: "password", + }) + pty.ExpectMatch("username") + }) +} diff --git a/cli/login.go b/cli/login.go index 0667a251f38dc..22a8f621823fb 100644 --- a/cli/login.go +++ b/cli/login.go @@ -285,5 +285,16 @@ func openURL(cmd *cobra.Command, urlToOpen string) error { return exec.Command("cmd.exe", "/c", "start", strings.ReplaceAll(urlToOpen, "&", "^&")).Start() } + browserEnv := os.Getenv("BROWSER") + if browserEnv != "" { + browserSh := fmt.Sprintf("%s '%s'", browserEnv, urlToOpen) + cmd := exec.CommandContext(cmd.Context(), "sh", "-c", browserSh) + out, err := cmd.CombinedOutput() + if err != nil { + return xerrors.Errorf("failed to run %v (out: %q): %w", cmd.Args, out, err) + } + return nil + } + return browser.OpenURL(urlToOpen) } diff --git a/cli/root.go b/cli/root.go index 2682e164a48c5..a68f5522b9cdd 100644 --- a/cli/root.go +++ b/cli/root.go @@ -25,6 +25,7 @@ import ( "github.com/coder/coder/cli/config" "github.com/coder/coder/cli/deployment" "github.com/coder/coder/coderd" + "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/codersdk" ) @@ -108,13 +109,33 @@ func AGPL() []*cobra.Command { } func Root(subcommands []*cobra.Command) *cobra.Command { + // The GIT_ASKPASS environment variable must point at + // a binary with no arguments. To prevent writing + // cross-platform scripts to invoke the Coder binary + // with a `gitaskpass` subcommand, we override the entrypoint + // to check if the command was invoked. + isGitAskpass := false + fmtLong := `Coder %s — A tool for provisioning self-hosted development environments with Terraform. ` cmd := &cobra.Command{ Use: "coder", SilenceErrors: true, SilenceUsage: true, - Long: fmt.Sprintf(fmtLong, buildinfo.Version()), + Long: fmt.Sprintf(fmtLong, buildinfo.Version()), + Args: func(cmd *cobra.Command, args []string) error { + if gitauth.CheckCommand(args, os.Environ()) { + isGitAskpass = true + return nil + } + return cobra.NoArgs(cmd, args) + }, + RunE: func(cmd *cobra.Command, args []string) error { + if isGitAskpass { + return gitAskpass().RunE(cmd, args) + } + return cmd.Help() + }, PersistentPreRun: func(cmd *cobra.Command, args []string) { if cliflag.IsSetBool(cmd, varNoVersionCheck) && cliflag.IsSetBool(cmd, varNoFeatureWarning) { @@ -134,6 +155,9 @@ func Root(subcommands []*cobra.Command) *cobra.Command { if cmd.Name() == "login" || cmd.Name() == "server" || cmd.Name() == "agent" || cmd.Name() == "gitssh" { return } + if isGitAskpass { + return + } client, err := CreateClient(cmd) // If we are unable to create a client, presumably the subcommand will fail as well diff --git a/cli/server.go b/cli/server.go index c46f1114fbbe3..93de4e5e34960 100644 --- a/cli/server.go +++ b/cli/server.go @@ -55,6 +55,7 @@ import ( "github.com/coder/coder/coderd/database/databasefake" "github.com/coder/coder/coderd/database/migrations" "github.com/coder/coder/coderd/devtunnel" + "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" @@ -96,7 +97,7 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co // be interrupted by additional signals. Note that we avoid // shadowing cancel() (from above) here because notifyStop() // restores default behavior for the signals. This protects - // the shutdown sequence from abrubtly terminating things + // the shutdown sequence from abruptly terminating things // like: database migrations, provisioner work, workspace // cleanup in dev-mode, etc. // @@ -326,6 +327,11 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co } } + gitAuthConfigs, err := gitauth.ConvertConfig(cfg.GitAuth.Value, accessURLParsed) + if err != nil { + return xerrors.Errorf("parse git auth config: %w", err) + } + realIPConfig, err := httpmw.ParseRealIPConfig(cfg.ProxyTrustedHeaders.Value, cfg.ProxyTrustedOrigins.Value) if err != nil { return xerrors.Errorf("parse real ip config: %w", err) @@ -341,6 +347,7 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co Pubsub: database.NewPubsubInMemory(), CacheDir: cfg.CacheDirectory.Value, GoogleTokenValidator: googleTokenValidator, + GitAuthConfigs: gitAuthConfigs, RealIPConfig: realIPConfig, SecureAuthCookie: cfg.SecureAuthCookie.Value, SSHKeygenAlgorithm: sshKeygenAlgorithm, @@ -424,6 +431,7 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co if err != nil { return xerrors.Errorf("scan version: %w", err) } + _ = version.Close() versionStr = strings.Split(versionStr, " ")[0] if semver.Compare("v"+versionStr, "v13") < 0 { return xerrors.New("PostgreSQL version must be v13.0.0 or higher!") diff --git a/coderd/coderd.go b/coderd/coderd.go index 6d9f38897755b..43ae621be144b 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -3,6 +3,7 @@ package coderd import ( "crypto/tls" "crypto/x509" + "fmt" "io" "net/http" "net/url" @@ -30,6 +31,7 @@ import ( "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/awsidentity" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" @@ -82,6 +84,7 @@ type Options struct { Telemetry telemetry.Reporter TracerProvider trace.TracerProvider AutoImportTemplates []AutoImportTemplate + GitAuthConfigs []*gitauth.Config RealIPConfig *httpmw.RealIPConfig // TLSCertificates is used to mesh DERP servers securely. @@ -262,6 +265,17 @@ func New(options *Options) *API { }) }) + r.Route("/gitauth", func(r chi.Router) { + for _, gitAuthConfig := range options.GitAuthConfigs { + r.Route(fmt.Sprintf("/%s", gitAuthConfig.ID), func(r chi.Router) { + r.Use( + httpmw.ExtractOAuth2(gitAuthConfig), + apiKeyMiddleware, + ) + r.Get("/callback", api.gitAuthCallback(gitAuthConfig)) + }) + } + }) r.Route("/api/v2", func(r chi.Router) { api.APIHandler = r @@ -474,6 +488,7 @@ func New(options *Options) *API { r.Get("/metadata", api.workspaceAgentMetadata) r.Post("/version", api.postWorkspaceAgentVersion) r.Post("/app-health", api.postWorkspaceAppHealth) + r.Get("/gitauth", api.workspaceAgentsGitAuth) r.Get("/gitsshkey", api.agentGitSSHKey) r.Get("/coordinate", api.workspaceAgentCoordinate) r.Get("/report-stats", api.workspaceAgentReportStats) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index ce28f28899211..f209e7e3b4999 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -57,6 +57,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { "POST:/api/v2/workspaceagents/aws-instance-identity": {NoAuthorize: true}, "POST:/api/v2/workspaceagents/azure-instance-identity": {NoAuthorize: true}, "POST:/api/v2/workspaceagents/google-instance-identity": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/me/gitauth": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/me/gitsshkey": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/me/metadata": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/me/coordinate": {NoAuthorize: true}, diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index f0b8e58f99c58..6758509635ffe 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -55,6 +55,7 @@ import ( "github.com/coder/coder/coderd/awsidentity" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/dbtestutil" + "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" @@ -88,6 +89,7 @@ type Options struct { AutobuildStats chan<- executor.Stats Auditor audit.Auditor TLSCertificates []tls.Certificate + GitAuthConfigs []*gitauth.Config // IncludeProvisionerDaemon when true means to start an in-memory provisionerD IncludeProvisionerDaemon bool @@ -235,6 +237,7 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can Database: options.Database, Pubsub: options.Pubsub, Experimental: options.Experimental, + GitAuthConfigs: options.GitAuthConfigs, Auditor: options.Auditor, AWSCertificates: options.AWSCertificates, diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index f72e278724549..d772e6d81a85e 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -34,6 +34,7 @@ func New() database.Store { organizationMembers: make([]database.OrganizationMember, 0), organizations: make([]database.Organization, 0), users: make([]database.User, 0), + gitAuthLinks: make([]database.GitAuthLink, 0), groups: make([]database.Group, 0), groupMembers: make([]database.GroupMember, 0), auditLogs: make([]database.AuditLog, 0), @@ -90,6 +91,7 @@ type data struct { agentStats []database.AgentStat auditLogs []database.AuditLog files []database.File + gitAuthLinks []database.GitAuthLink gitSSHKey []database.GitSSHKey groups []database.Group groupMembers []database.GroupMember @@ -3438,3 +3440,54 @@ func (q *fakeQuerier) GetReplicasUpdatedAfter(_ context.Context, updatedAt time. } return replicas, nil } + +func (q *fakeQuerier) GetGitAuthLink(_ context.Context, arg database.GetGitAuthLinkParams) (database.GitAuthLink, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + for _, gitAuthLink := range q.gitAuthLinks { + if arg.UserID != gitAuthLink.UserID { + continue + } + if arg.ProviderID != gitAuthLink.ProviderID { + continue + } + return gitAuthLink, nil + } + return database.GitAuthLink{}, sql.ErrNoRows +} + +func (q *fakeQuerier) InsertGitAuthLink(_ context.Context, arg database.InsertGitAuthLinkParams) (database.GitAuthLink, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + // nolint:gosimple + gitAuthLink := database.GitAuthLink{ + ProviderID: arg.ProviderID, + UserID: arg.UserID, + CreatedAt: arg.CreatedAt, + UpdatedAt: arg.UpdatedAt, + OAuthAccessToken: arg.OAuthAccessToken, + OAuthRefreshToken: arg.OAuthRefreshToken, + OAuthExpiry: arg.OAuthExpiry, + } + q.gitAuthLinks = append(q.gitAuthLinks, gitAuthLink) + return gitAuthLink, nil +} + +func (q *fakeQuerier) UpdateGitAuthLink(_ context.Context, arg database.UpdateGitAuthLinkParams) error { + q.mutex.Lock() + defer q.mutex.Unlock() + for index, gitAuthLink := range q.gitAuthLinks { + if gitAuthLink.ProviderID != arg.ProviderID { + continue + } + if gitAuthLink.UserID != arg.UserID { + continue + } + gitAuthLink.UpdatedAt = arg.UpdatedAt + gitAuthLink.OAuthAccessToken = arg.OAuthAccessToken + gitAuthLink.OAuthRefreshToken = arg.OAuthRefreshToken + gitAuthLink.OAuthExpiry = arg.OAuthExpiry + q.gitAuthLinks[index] = gitAuthLink + } + return nil +} diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f3710e91f037b..d601ac60cdd16 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -162,6 +162,16 @@ CREATE TABLE files ( id uuid DEFAULT gen_random_uuid() NOT NULL ); +CREATE TABLE git_auth_links ( + provider_id text NOT NULL, + user_id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + oauth_access_token text NOT NULL, + oauth_refresh_token text NOT NULL, + oauth_expiry timestamp with time zone NOT NULL +); + CREATE TABLE gitsshkeys ( user_id uuid NOT NULL, created_at timestamp with time zone NOT NULL, @@ -462,6 +472,9 @@ ALTER TABLE ONLY files ALTER TABLE ONLY files ADD CONSTRAINT files_pkey PRIMARY KEY (id); +ALTER TABLE ONLY git_auth_links + ADD CONSTRAINT git_auth_links_provider_id_user_id_key UNIQUE (provider_id, user_id); + ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_pkey PRIMARY KEY (user_id); diff --git a/coderd/database/migrations/000064_gitauth.down.sql b/coderd/database/migrations/000064_gitauth.down.sql new file mode 100644 index 0000000000000..758396230a861 --- /dev/null +++ b/coderd/database/migrations/000064_gitauth.down.sql @@ -0,0 +1 @@ +DROP TABLE git_auth_links; diff --git a/coderd/database/migrations/000064_gitauth.up.sql b/coderd/database/migrations/000064_gitauth.up.sql new file mode 100644 index 0000000000000..7f4cb9f54c34e --- /dev/null +++ b/coderd/database/migrations/000064_gitauth.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS git_auth_links ( + provider_id text NOT NULL, + user_id uuid NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + oauth_access_token text NOT NULL, + oauth_refresh_token text NOT NULL, + oauth_expiry timestamptz NOT NULL, + UNIQUE(provider_id, user_id) +); diff --git a/coderd/database/models.go b/coderd/database/models.go index dd0bce6064514..83e4610c40455 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -428,6 +428,16 @@ type File struct { ID uuid.UUID `db:"id" json:"id"` } +type GitAuthLink struct { + ProviderID string `db:"provider_id" json:"provider_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"` + OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"` + OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"` +} + type GitSSHKey struct { UserID uuid.UUID `db:"user_id" json:"user_id"` CreatedAt time.Time `db:"created_at" json:"created_at"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 557cfa141776f..69eb998f59d57 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -44,6 +44,7 @@ type sqlcQuerier interface { GetDeploymentID(ctx context.Context) (string, error) GetFileByHashAndCreator(ctx context.Context, arg GetFileByHashAndCreatorParams) (File, error) GetFileByID(ctx context.Context, id uuid.UUID) (File, error) + GetGitAuthLink(ctx context.Context, arg GetGitAuthLinkParams) (GitAuthLink, error) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error) @@ -132,6 +133,7 @@ type sqlcQuerier interface { InsertDERPMeshKey(ctx context.Context, value string) error InsertDeploymentID(ctx context.Context, value string) error InsertFile(ctx context.Context, arg InsertFileParams) (File, error) + InsertGitAuthLink(ctx context.Context, arg InsertGitAuthLinkParams) (GitAuthLink, error) InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error) InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error) InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error @@ -157,6 +159,7 @@ type sqlcQuerier interface { ParameterValue(ctx context.Context, id uuid.UUID) (ParameterValue, error) ParameterValues(ctx context.Context, arg ParameterValuesParams) ([]ParameterValue, error) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error + UpdateGitAuthLink(ctx context.Context, arg UpdateGitAuthLinkParams) error UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error) UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d9c34db7001fb..b99d651016fb3 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -753,6 +753,113 @@ func (q *sqlQuerier) InsertFile(ctx context.Context, arg InsertFileParams) (File return i, err } +const getGitAuthLink = `-- name: GetGitAuthLink :one +SELECT provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry FROM git_auth_links WHERE provider_id = $1 AND user_id = $2 +` + +type GetGitAuthLinkParams struct { + ProviderID string `db:"provider_id" json:"provider_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` +} + +func (q *sqlQuerier) GetGitAuthLink(ctx context.Context, arg GetGitAuthLinkParams) (GitAuthLink, error) { + row := q.db.QueryRowContext(ctx, getGitAuthLink, arg.ProviderID, arg.UserID) + var i GitAuthLink + err := row.Scan( + &i.ProviderID, + &i.UserID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OAuthAccessToken, + &i.OAuthRefreshToken, + &i.OAuthExpiry, + ) + return i, err +} + +const insertGitAuthLink = `-- name: InsertGitAuthLink :one +INSERT INTO git_auth_links ( + provider_id, + user_id, + created_at, + updated_at, + oauth_access_token, + oauth_refresh_token, + oauth_expiry +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7 +) RETURNING provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry +` + +type InsertGitAuthLinkParams struct { + ProviderID string `db:"provider_id" json:"provider_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"` + OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"` + OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"` +} + +func (q *sqlQuerier) InsertGitAuthLink(ctx context.Context, arg InsertGitAuthLinkParams) (GitAuthLink, error) { + row := q.db.QueryRowContext(ctx, insertGitAuthLink, + arg.ProviderID, + arg.UserID, + arg.CreatedAt, + arg.UpdatedAt, + arg.OAuthAccessToken, + arg.OAuthRefreshToken, + arg.OAuthExpiry, + ) + var i GitAuthLink + err := row.Scan( + &i.ProviderID, + &i.UserID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OAuthAccessToken, + &i.OAuthRefreshToken, + &i.OAuthExpiry, + ) + return i, err +} + +const updateGitAuthLink = `-- name: UpdateGitAuthLink :exec +UPDATE git_auth_links SET + updated_at = $3, + oauth_access_token = $4, + oauth_refresh_token = $5, + oauth_expiry = $6 +WHERE provider_id = $1 AND user_id = $2 +` + +type UpdateGitAuthLinkParams struct { + ProviderID string `db:"provider_id" json:"provider_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"` + OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"` + OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"` +} + +func (q *sqlQuerier) UpdateGitAuthLink(ctx context.Context, arg UpdateGitAuthLinkParams) error { + _, err := q.db.ExecContext(ctx, updateGitAuthLink, + arg.ProviderID, + arg.UserID, + arg.UpdatedAt, + arg.OAuthAccessToken, + arg.OAuthRefreshToken, + arg.OAuthExpiry, + ) + return err +} + const deleteGitSSHKey = `-- name: DeleteGitSSHKey :exec DELETE FROM gitsshkeys diff --git a/coderd/database/queries/gitauth.sql b/coderd/database/queries/gitauth.sql new file mode 100644 index 0000000000000..52a6fa76c48f4 --- /dev/null +++ b/coderd/database/queries/gitauth.sql @@ -0,0 +1,29 @@ +-- name: GetGitAuthLink :one +SELECT * FROM git_auth_links WHERE provider_id = $1 AND user_id = $2; + +-- name: InsertGitAuthLink :one +INSERT INTO git_auth_links ( + provider_id, + user_id, + created_at, + updated_at, + oauth_access_token, + oauth_refresh_token, + oauth_expiry +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7 +) RETURNING *; + +-- name: UpdateGitAuthLink :exec +UPDATE git_auth_links SET + updated_at = $3, + oauth_access_token = $4, + oauth_refresh_token = $5, + oauth_expiry = $6 +WHERE provider_id = $1 AND user_id = $2; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index b4263c09b4762..dbaddd46838c8 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -7,6 +7,7 @@ type UniqueConstraint string // UniqueConstraint enums. const ( UniqueFilesHashCreatedByKey UniqueConstraint = "files_hash_created_by_key" // ALTER TABLE ONLY files ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); + UniqueGitAuthLinksProviderIDUserIDKey UniqueConstraint = "git_auth_links_provider_id_user_id_key" // ALTER TABLE ONLY git_auth_links ADD CONSTRAINT git_auth_links_provider_id_user_id_key UNIQUE (provider_id, user_id); UniqueGroupMembersUserIDGroupIDKey UniqueConstraint = "group_members_user_id_group_id_key" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_group_id_key UNIQUE (user_id, group_id); UniqueGroupsNameOrganizationIDKey UniqueConstraint = "groups_name_organization_id_key" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_name_organization_id_key UNIQUE (name, organization_id); UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt); diff --git a/coderd/gitauth/askpass.go b/coderd/gitauth/askpass.go new file mode 100644 index 0000000000000..e78a5bf20661f --- /dev/null +++ b/coderd/gitauth/askpass.go @@ -0,0 +1,70 @@ +package gitauth + +import ( + "net/url" + "regexp" + "strings" + + "golang.org/x/xerrors" +) + +// https://github.com/microsoft/vscode/blob/328646ebc2f5016a1c67e0b23a0734bd598ec5a8/extensions/git/src/askpass-main.ts#L46 +var hostReplace = regexp.MustCompile(`^["']+|["':]+$`) + +// CheckCommand returns true if the command arguments and environment +// match those when the GIT_ASKPASS command is invoked by git. +func CheckCommand(args, env []string) bool { + if len(args) != 1 || (!strings.HasPrefix(args[0], "Username ") && !strings.HasPrefix(args[0], "Password ")) { + return false + } + for _, e := range env { + if strings.HasPrefix(e, "GIT_PREFIX=") { + return true + } + } + return false +} + +// ParseAskpass returns the user and host from a git askpass prompt. For +// example: "user1" and "https://github.com". Note that for HTTP +// protocols, the URL will never contain a path. +// +// For details on how the prompt is formatted, see `credential_ask_one`: +// https://github.com/git/git/blob/bbe21b64a08f89475d8a3818e20c111378daa621/credential.c#L173-L191 +func ParseAskpass(prompt string) (user string, host string, err error) { + parts := strings.Fields(prompt) + if len(parts) < 3 { + return "", "", xerrors.Errorf("askpass prompt must contain 3 words; got %d: %q", len(parts), prompt) + } + + switch parts[0] { + case "Username", "Password": + default: + return "", "", xerrors.Errorf("unknown prompt type: %q", prompt) + } + + host = parts[2] + host = hostReplace.ReplaceAllString(host, "") + + // Validate the input URL to ensure it's in an expected format. + u, err := url.Parse(host) + if err != nil { + return "", "", xerrors.Errorf("parse host failed: %w", err) + } + + switch u.Scheme { + case "http", "https": + default: + return "", "", xerrors.Errorf("unsupported scheme: %q", u.Scheme) + } + + if u.Host == "" { + return "", "", xerrors.Errorf("host is empty") + } + + user = u.User.Username() + u.User = nil + host = u.String() + + return user, host, nil +} diff --git a/coderd/gitauth/askpass_test.go b/coderd/gitauth/askpass_test.go new file mode 100644 index 0000000000000..ce7cc75989603 --- /dev/null +++ b/coderd/gitauth/askpass_test.go @@ -0,0 +1,72 @@ +package gitauth_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/gitauth" +) + +func TestCheckCommand(t *testing.T) { + t.Parallel() + t.Run("Success", func(t *testing.T) { + t.Parallel() + valid := gitauth.CheckCommand([]string{"Username "}, []string{"GIT_PREFIX=/example"}) + require.True(t, valid) + }) + t.Run("Failure", func(t *testing.T) { + t.Parallel() + valid := gitauth.CheckCommand([]string{}, []string{}) + require.False(t, valid) + }) +} + +func TestParse(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + in string + wantUser string + wantHost string + }{ + { + in: "Username for 'https://github.com': ", + wantUser: "", + wantHost: "https://github.com", + }, + { + in: "Username for 'https://enterprise.github.com': ", + wantUser: "", + wantHost: "https://enterprise.github.com", + }, + { + in: "Username for 'http://wow.io': ", + wantUser: "", + wantHost: "http://wow.io", + }, + { + in: "Password for 'https://myuser@github.com': ", + wantUser: "myuser", + wantHost: "https://github.com", + }, + { + in: "Password for 'https://myuser@enterprise.github.com': ", + wantUser: "myuser", + wantHost: "https://enterprise.github.com", + }, + { + in: "Password for 'http://myuser@wow.io': ", + wantUser: "myuser", + wantHost: "http://wow.io", + }, + } { + tc := tc + t.Run(tc.in, func(t *testing.T) { + t.Parallel() + user, host, err := gitauth.ParseAskpass(tc.in) + require.NoError(t, err) + require.Equal(t, tc.wantUser, user) + require.Equal(t, tc.wantHost, host) + }) + } +} diff --git a/coderd/gitauth/config.go b/coderd/gitauth/config.go new file mode 100644 index 0000000000000..8216446390839 --- /dev/null +++ b/coderd/gitauth/config.go @@ -0,0 +1,103 @@ +package gitauth + +import ( + "fmt" + "net/url" + "regexp" + + "golang.org/x/oauth2" + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/codersdk" +) + +// Config is used for authentication for Git operations. +type Config struct { + httpmw.OAuth2Config + // ID is a unique identifier for the authenticator. + ID string + // Regex is a regexp that URLs will match against. + Regex *regexp.Regexp + // Type is the type of provider. + Type codersdk.GitProvider +} + +// ConvertConfig converts the YAML configuration entry to the +// parsed and ready-to-consume provider type. +func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Config, error) { + ids := map[string]struct{}{} + configs := []*Config{} + for _, entry := range entries { + var typ codersdk.GitProvider + switch entry.Type { + case codersdk.GitProviderAzureDevops: + typ = codersdk.GitProviderAzureDevops + case codersdk.GitProviderBitBucket: + typ = codersdk.GitProviderBitBucket + case codersdk.GitProviderGitHub: + typ = codersdk.GitProviderGitHub + case codersdk.GitProviderGitLab: + typ = codersdk.GitProviderGitLab + default: + return nil, xerrors.Errorf("unknown git provider type: %q", entry.Type) + } + if entry.ID == "" { + // Default to the type. + entry.ID = string(typ) + } + if valid := httpapi.UsernameValid(entry.ID); valid != nil { + return nil, xerrors.Errorf("git auth provider %q doesn't have a valid id: %w", entry.ID, valid) + } + + _, exists := ids[entry.ID] + if exists { + if entry.ID == string(typ) { + return nil, xerrors.Errorf("multiple %s git auth providers provided. you must specify a unique id for each", typ) + } + return nil, xerrors.Errorf("multiple git providers exist with the id %q. specify a unique id for each", entry.ID) + } + ids[entry.ID] = struct{}{} + + if entry.ClientID == "" { + return nil, xerrors.Errorf("%q git auth provider: client_id must be provided", entry.ID) + } + if entry.ClientSecret == "" { + return nil, xerrors.Errorf("%q git auth provider: client_secret must be provided", entry.ID) + } + authRedirect, err := accessURL.Parse(fmt.Sprintf("/gitauth/%s/callback", entry.ID)) + if err != nil { + return nil, xerrors.Errorf("parse gitauth callback url: %w", err) + } + regex := regex[typ] + if entry.Regex != "" { + regex, err = regexp.Compile(entry.Regex) + if err != nil { + return nil, xerrors.Errorf("compile regex for git auth provider %q: %w", entry.ID, entry.Regex) + } + } + + oauth2Config := &oauth2.Config{ + ClientID: entry.ClientID, + ClientSecret: entry.ClientSecret, + Endpoint: endpoint[typ], + RedirectURL: authRedirect.String(), + Scopes: scope[typ], + } + + var oauthConfig httpmw.OAuth2Config = oauth2Config + // Azure DevOps uses JWT token authentication! + if typ == codersdk.GitProviderAzureDevops { + oauthConfig = newJWTOAuthConfig(oauth2Config) + } + + configs = append(configs, &Config{ + OAuth2Config: oauthConfig, + ID: entry.ID, + Regex: regex, + Type: typ, + }) + } + return configs, nil +} diff --git a/coderd/gitauth/config_test.go b/coderd/gitauth/config_test.go new file mode 100644 index 0000000000000..07ad7f1ac74a2 --- /dev/null +++ b/coderd/gitauth/config_test.go @@ -0,0 +1,78 @@ +package gitauth_test + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/gitauth" + "github.com/coder/coder/codersdk" +) + +func TestConvertYAML(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + Name string + Input []codersdk.GitAuthConfig + Output []*gitauth.Config + Error string + }{{ + Name: "InvalidType", + Input: []codersdk.GitAuthConfig{{ + Type: "moo", + }}, + Error: "unknown git provider type", + }, { + Name: "InvalidID", + Input: []codersdk.GitAuthConfig{{ + Type: codersdk.GitProviderGitHub, + ID: "$hi$", + }}, + Error: "doesn't have a valid id", + }, { + Name: "NoClientID", + Input: []codersdk.GitAuthConfig{{ + Type: codersdk.GitProviderGitHub, + }}, + Error: "client_id must be provided", + }, { + Name: "NoClientSecret", + Input: []codersdk.GitAuthConfig{{ + Type: codersdk.GitProviderGitHub, + ClientID: "example", + }}, + Error: "client_secret must be provided", + }, { + Name: "DuplicateType", + Input: []codersdk.GitAuthConfig{{ + Type: codersdk.GitProviderGitHub, + ClientID: "example", + ClientSecret: "example", + }, { + Type: codersdk.GitProviderGitHub, + }}, + Error: "multiple github git auth providers provided", + }, { + Name: "InvalidRegex", + Input: []codersdk.GitAuthConfig{{ + Type: codersdk.GitProviderGitHub, + ClientID: "example", + ClientSecret: "example", + Regex: `\K`, + }}, + Error: "compile regex for git auth provider", + }} { + tc := tc + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + output, err := gitauth.ConvertConfig(tc.Input, &url.URL{}) + if tc.Error != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.Error) + return + } + require.Equal(t, tc.Output, output) + }) + } +} diff --git a/coderd/gitauth/oauth.go b/coderd/gitauth/oauth.go new file mode 100644 index 0000000000000..f1c63515f32a8 --- /dev/null +++ b/coderd/gitauth/oauth.go @@ -0,0 +1,83 @@ +package gitauth + +import ( + "context" + "net/url" + "regexp" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/github" + + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/codersdk" +) + +// endpoint contains default SaaS URLs for each Git provider. +var endpoint = map[codersdk.GitProvider]oauth2.Endpoint{ + codersdk.GitProviderAzureDevops: { + AuthURL: "https://app.vssps.visualstudio.com/oauth2/authorize", + TokenURL: "https://app.vssps.visualstudio.com/oauth2/token", + }, + codersdk.GitProviderBitBucket: { + AuthURL: "https://bitbucket.org/site/oauth2/authorize", + TokenURL: "https://bitbucket.org/site/oauth2/access_token", + }, + codersdk.GitProviderGitLab: { + AuthURL: "https://gitlab.com/oauth/authorize", + TokenURL: "https://gitlab.com/oauth/token", + }, + codersdk.GitProviderGitHub: github.Endpoint, +} + +// scope contains defaults for each Git provider. +var scope = map[codersdk.GitProvider][]string{ + codersdk.GitProviderAzureDevops: {"vso.code_write"}, + codersdk.GitProviderBitBucket: {"repository:write"}, + codersdk.GitProviderGitLab: {"write_repository"}, + codersdk.GitProviderGitHub: {"repo"}, +} + +// regex provides defaults for each Git provider to +// match their SaaS host URL. This is configurable by each provider. +var regex = map[codersdk.GitProvider]*regexp.Regexp{ + codersdk.GitProviderAzureDevops: regexp.MustCompile(`dev\.azure\.com`), + codersdk.GitProviderBitBucket: regexp.MustCompile(`bitbucket\.org`), + codersdk.GitProviderGitLab: regexp.MustCompile(`gitlab\.com`), + codersdk.GitProviderGitHub: regexp.MustCompile(`github\.com`), +} + +// newJWTOAuthConfig creates a new OAuth2 config that uses a custom +// assertion method that works with Azure Devops. See: +// https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/oauth?view=azure-devops +func newJWTOAuthConfig(config *oauth2.Config) httpmw.OAuth2Config { + return &jwtConfig{config} +} + +type jwtConfig struct { + *oauth2.Config +} + +func (c *jwtConfig) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { + return c.Config.AuthCodeURL(state, append(opts, oauth2.SetAuthURLParam("response_type", "Assertion"))...) +} + +func (c *jwtConfig) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + v := url.Values{ + "client_assertion_type": {}, + "client_assertion": {c.ClientSecret}, + "assertion": {code}, + "grant_type": {}, + } + if c.RedirectURL != "" { + v.Set("redirect_uri", c.RedirectURL) + } + return c.Config.Exchange(ctx, code, + append(opts, + oauth2.SetAuthURLParam("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"), + oauth2.SetAuthURLParam("client_assertion", c.ClientSecret), + oauth2.SetAuthURLParam("assertion", code), + oauth2.SetAuthURLParam("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + oauth2.SetAuthURLParam("code", ""), + )..., + ) +} diff --git a/coderd/gitauth/oauth_test.go b/coderd/gitauth/oauth_test.go new file mode 100644 index 0000000000000..b85baeb935ed5 --- /dev/null +++ b/coderd/gitauth/oauth_test.go @@ -0,0 +1,9 @@ +package gitauth_test + +import ( + "testing" +) + +func TestOAuthJWTConfig(t *testing.T) { + t.Parallel() +} diff --git a/coderd/gitauth/vscode.go b/coderd/gitauth/vscode.go new file mode 100644 index 0000000000000..8ab178b080e15 --- /dev/null +++ b/coderd/gitauth/vscode.go @@ -0,0 +1,81 @@ +package gitauth + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + + "github.com/adrg/xdg" + "github.com/spf13/afero" + "golang.org/x/xerrors" +) + +// OverrideVSCodeConfigs overwrites a few properties to consume +// GIT_ASKPASS from the host instead of VS Code-specific authentication. +func OverrideVSCodeConfigs(fs afero.Fs) error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + mutate := func(m map[string]interface{}) { + // This prevents VS Code from overriding GIT_ASKPASS, which + // we use to automatically authenticate Git providers. + m["git.useIntegratedAskPass"] = false + // This prevents VS Code from using it's own GitHub authentication + // which would circumvent cloning with Coder-configured providers. + m["github.gitAuthentication"] = false + } + + for _, configPath := range []string{ + // code-server's default configuration path. + filepath.Join(xdg.DataHome, "code-server", "Machine", "settings.json"), + // vscode-remote's default configuration path. + filepath.Join(home, ".vscode-server", "data", "Machine", "settings.json"), + } { + _, err := fs.Stat(configPath) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return xerrors.Errorf("stat %q: %w", configPath, err) + } + + m := map[string]interface{}{} + mutate(m) + data, err := json.MarshalIndent(m, "", "\t") + if err != nil { + return xerrors.Errorf("marshal: %w", err) + } + + err = fs.MkdirAll(filepath.Dir(configPath), 0o700) + if err != nil { + return xerrors.Errorf("mkdir all: %w", err) + } + + err = afero.WriteFile(fs, configPath, data, 0600) + if err != nil { + return xerrors.Errorf("write %q: %w", configPath, err) + } + continue + } + + data, err := afero.ReadFile(fs, configPath) + if err != nil { + return xerrors.Errorf("read %q: %w", configPath, err) + } + mapping := map[string]interface{}{} + err = json.Unmarshal(data, &mapping) + if err != nil { + return xerrors.Errorf("unmarshal %q: %w", configPath, err) + } + mutate(mapping) + data, err = json.MarshalIndent(mapping, "", "\t") + if err != nil { + return xerrors.Errorf("marshal %q: %w", configPath, err) + } + err = afero.WriteFile(fs, configPath, data, 0600) + if err != nil { + return xerrors.Errorf("write %q: %w", configPath, err) + } + } + return nil +} diff --git a/coderd/gitauth/vscode_test.go b/coderd/gitauth/vscode_test.go new file mode 100644 index 0000000000000..fca7994d93134 --- /dev/null +++ b/coderd/gitauth/vscode_test.go @@ -0,0 +1,64 @@ +package gitauth_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/adrg/xdg" + "github.com/spf13/afero" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/gitauth" +) + +func TestOverrideVSCodeConfigs(t *testing.T) { + t.Parallel() + home, err := os.UserHomeDir() + require.NoError(t, err) + configPaths := []string{ + filepath.Join(xdg.DataHome, "code-server", "Machine", "settings.json"), + filepath.Join(home, ".vscode-server", "data", "Machine", "settings.json"), + } + t.Run("Create", func(t *testing.T) { + t.Parallel() + fs := afero.NewMemMapFs() + err := gitauth.OverrideVSCodeConfigs(fs) + require.NoError(t, err) + for _, configPath := range configPaths { + data, err := afero.ReadFile(fs, configPath) + require.NoError(t, err) + mapping := map[string]interface{}{} + err = json.Unmarshal(data, &mapping) + require.NoError(t, err) + require.Equal(t, false, mapping["git.useIntegratedAskPass"]) + require.Equal(t, false, mapping["github.gitAuthentication"]) + } + }) + t.Run("Append", func(t *testing.T) { + t.Parallel() + fs := afero.NewMemMapFs() + mapping := map[string]interface{}{ + "hotdogs": "something", + } + data, err := json.Marshal(mapping) + require.NoError(t, err) + for _, configPath := range configPaths { + err = afero.WriteFile(fs, configPath, data, 0600) + require.NoError(t, err) + } + err = gitauth.OverrideVSCodeConfigs(fs) + require.NoError(t, err) + for _, configPath := range configPaths { + data, err := afero.ReadFile(fs, configPath) + require.NoError(t, err) + mapping := map[string]interface{}{} + err = json.Unmarshal(data, &mapping) + require.NoError(t, err) + require.Equal(t, false, mapping["git.useIntegratedAskPass"]) + require.Equal(t, false, mapping["github.gitAuthentication"]) + require.Equal(t, "something", mapping["hotdogs"]) + } + }) +} diff --git a/coderd/httpapi/httpapi.go b/coderd/httpapi/httpapi.go index 3d3def7ee493e..a42b79eaa4db8 100644 --- a/coderd/httpapi/httpapi.go +++ b/coderd/httpapi/httpapi.go @@ -40,7 +40,8 @@ func init() { if !ok { return false } - return UsernameValid(str) + valid := UsernameValid(str) + return valid == nil } for _, tag := range []string{"username", "template_name", "workspace_name"} { err := validate.RegisterValidation(tag, nameValidator) diff --git a/coderd/httpapi/username.go b/coderd/httpapi/username.go index f41e0d96d3205..89a9cce92016a 100644 --- a/coderd/httpapi/username.go +++ b/coderd/httpapi/username.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/moby/moby/pkg/namesgenerator" + "golang.org/x/xerrors" ) var ( @@ -13,14 +14,18 @@ var ( ) // UsernameValid returns whether the input string is a valid username. -func UsernameValid(str string) bool { +func UsernameValid(str string) error { if len(str) > 32 { - return false + return xerrors.New("must be <= 32 characters") } if len(str) < 1 { - return false + return xerrors.New("must be >= 1 character") } - return UsernameValidRegex.MatchString(str) + matched := UsernameValidRegex.MatchString(str) + if !matched { + return xerrors.New("must be alphanumeric with hyphens") + } + return nil } // UsernameFrom returns a best-effort username from the provided string. @@ -30,7 +35,7 @@ func UsernameValid(str string) bool { // the username from an email address. If no success happens during // these steps, a random username will be returned. func UsernameFrom(str string) string { - if UsernameValid(str) { + if valid := UsernameValid(str); valid == nil { return str } emailAt := strings.LastIndex(str, "@") @@ -38,7 +43,7 @@ func UsernameFrom(str string) string { str = str[:emailAt] } str = usernameReplace.ReplaceAllString(str, "") - if UsernameValid(str) { + if valid := UsernameValid(str); valid == nil { return str } return strings.ReplaceAll(namesgenerator.GetRandomName(1), "_", "-") diff --git a/coderd/httpapi/username_test.go b/coderd/httpapi/username_test.go index fa6c0e1434233..547d5177c4e56 100644 --- a/coderd/httpapi/username_test.go +++ b/coderd/httpapi/username_test.go @@ -59,7 +59,8 @@ func TestValid(t *testing.T) { testCase := testCase t.Run(testCase.Username, func(t *testing.T) { t.Parallel() - require.Equal(t, testCase.Valid, httpapi.UsernameValid(testCase.Username)) + valid := httpapi.UsernameValid(testCase.Username) + require.Equal(t, testCase.Valid, valid == nil) }) } } @@ -91,7 +92,8 @@ func TestFrom(t *testing.T) { t.Parallel() converted := httpapi.UsernameFrom(testCase.From) t.Log(converted) - require.True(t, httpapi.UsernameValid(converted)) + valid := httpapi.UsernameValid(converted) + require.True(t, valid == nil) if testCase.Match == "" { require.NotEqual(t, testCase.From, converted) } else { diff --git a/coderd/userauth.go b/coderd/userauth.go index 30c66a0c585a9..b1392d0569b83 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -261,7 +261,8 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { // The username is a required property in Coder. We make a best-effort // attempt at using what the claims provide, but if that fails we will // generate a random username. - if !httpapi.UsernameValid(username) { + usernameValid := httpapi.UsernameValid(username) + if usernameValid != nil { // If no username is provided, we can default to use the email address. // This will be converted in the from function below, so it's safe // to keep the domain. diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index ba4b437bdb8b3..d21808052e885 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -9,6 +9,7 @@ import ( "net/url" "strings" "testing" + "time" "github.com/coreos/go-oidc/v3/oidc" "github.com/golang-jwt/jwt" @@ -20,6 +21,7 @@ import ( "github.com/coder/coder/coderd" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" "github.com/coder/coder/testutil" ) @@ -37,12 +39,31 @@ func (o *oauth2Config) Exchange(context.Context, string, ...oauth2.AuthCodeOptio return o.token, nil } return &oauth2.Token{ - AccessToken: "token", + AccessToken: "token", + RefreshToken: "refresh", + Expiry: database.Now().Add(time.Hour), }, nil } -func (*oauth2Config) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource { - return nil +func (o *oauth2Config) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource { + return &oauth2TokenSource{ + token: o.token, + } +} + +type oauth2TokenSource struct { + token *oauth2.Token +} + +func (o *oauth2TokenSource) Token() (*oauth2.Token, error) { + if o.token != nil { + return o.token, nil + } + return &oauth2.Token{ + AccessToken: "token", + RefreshToken: "refresh", + Expiry: database.Now().Add(time.Hour), + }, nil } func TestUserAuthMethods(t *testing.T) { diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 67315645ef7c7..edae77f37777e 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "io" "net" @@ -18,6 +19,7 @@ import ( "github.com/google/uuid" "go.opentelemetry.io/otel/trace" "golang.org/x/mod/semver" + "golang.org/x/oauth2" "golang.org/x/xerrors" "nhooyr.io/websocket" "nhooyr.io/websocket/wsjson" @@ -25,6 +27,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" @@ -84,6 +87,7 @@ func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request) httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentMetadata{ Apps: convertApps(dbApps), DERPMap: api.DERPMap, + GitAuthConfigs: len(api.GitAuthConfigs), EnvironmentVariables: apiAgent.EnvironmentVariables, StartupScript: apiAgent.StartupScript, Directory: apiAgent.Directory, @@ -925,6 +929,272 @@ func (api *API) postWorkspaceAppHealth(rw http.ResponseWriter, r *http.Request) httpapi.Write(r.Context(), rw, http.StatusOK, nil) } +// postWorkspaceAgentsGitAuth returns a username and password for use +// with GIT_ASKPASS. +func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + gitURL := r.URL.Query().Get("url") + if gitURL == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Missing 'url' query parameter!", + }) + return + } + // listen determines if the request will wait for a + // new token to be issued! + listen := r.URL.Query().Has("listen") + + var gitAuthConfig *gitauth.Config + for _, gitAuth := range api.GitAuthConfigs { + matches := gitAuth.Regex.MatchString(gitURL) + if !matches { + continue + } + gitAuthConfig = gitAuth + } + if gitAuthConfig == nil { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: fmt.Sprintf("No git provider found for URL %q", gitURL), + }) + return + } + workspaceAgent := httpmw.WorkspaceAgent(r) + // We must get the workspace to get the owner ID! + resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get workspace resource.", + Detail: err.Error(), + }) + return + } + build, err := api.Database.GetWorkspaceBuildByJobID(ctx, resource.JobID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get build.", + Detail: err.Error(), + }) + return + } + workspace, err := api.Database.GetWorkspaceByID(ctx, build.WorkspaceID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get workspace.", + Detail: err.Error(), + }) + return + } + + if listen { + // If listening we await a new token... + authChan := make(chan struct{}, 1) + cancelFunc, err := api.Pubsub.Subscribe("gitauth", func(ctx context.Context, message []byte) { + ids := strings.Split(string(message), "|") + if len(ids) != 2 { + return + } + if ids[0] != gitAuthConfig.ID { + return + } + if ids[1] != workspace.OwnerID.String() { + return + } + select { + case authChan <- struct{}{}: + default: + } + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to listen for git auth token.", + Detail: err.Error(), + }) + return + } + defer cancelFunc() + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-r.Context().Done(): + return + case <-ticker.C: + case <-authChan: + } + gitAuthLink, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ + ProviderID: gitAuthConfig.ID, + UserID: workspace.OwnerID, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get git auth link.", + Detail: err.Error(), + }) + return + } + if gitAuthLink.OAuthExpiry.Before(database.Now()) { + continue + } + httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(gitAuthConfig.Type, gitAuthLink.OAuthAccessToken)) + return + } + } + + // This is the URL that will redirect the user with a state token. + redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/gitauth/%s", gitAuthConfig.ID)) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to parse access URL.", + Detail: err.Error(), + }) + return + } + + gitAuthLink, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ + ProviderID: gitAuthConfig.ID, + UserID: workspace.OwnerID, + }) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get git auth link.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentGitAuthResponse{ + URL: redirectURL.String(), + }) + return + } + + token, err := gitAuthConfig.TokenSource(ctx, &oauth2.Token{ + AccessToken: gitAuthLink.OAuthAccessToken, + RefreshToken: gitAuthLink.OAuthRefreshToken, + Expiry: gitAuthLink.OAuthExpiry, + }).Token() + if err != nil { + httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentGitAuthResponse{ + URL: redirectURL.String(), + }) + return + } + + if token.AccessToken != gitAuthLink.OAuthAccessToken { + // Update it + err = api.Database.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{ + ProviderID: gitAuthConfig.ID, + UserID: workspace.OwnerID, + UpdatedAt: database.Now(), + OAuthAccessToken: token.AccessToken, + OAuthRefreshToken: token.RefreshToken, + OAuthExpiry: token.Expiry, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update git auth link.", + Detail: err.Error(), + }) + return + } + } + httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(gitAuthConfig.Type, token.AccessToken)) +} + +// Provider types have different username/password formats. +func formatGitAuthAccessToken(typ codersdk.GitProvider, token string) codersdk.WorkspaceAgentGitAuthResponse { + var resp codersdk.WorkspaceAgentGitAuthResponse + switch typ { + case codersdk.GitProviderGitLab: + // https://stackoverflow.com/questions/25409700/using-gitlab-token-to-clone-without-authentication + resp = codersdk.WorkspaceAgentGitAuthResponse{ + Username: "oauth2", + Password: token, + } + case codersdk.GitProviderBitBucket: + // https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/#Cloning-a-repository-with-an-access-token + resp = codersdk.WorkspaceAgentGitAuthResponse{ + Username: "x-token-auth", + Password: token, + } + default: + resp = codersdk.WorkspaceAgentGitAuthResponse{ + Username: token, + } + } + return resp +} + +func (api *API) gitAuthCallback(gitAuthConfig *gitauth.Config) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + state = httpmw.OAuth2(r) + apiKey = httpmw.APIKey(r) + ) + + _, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ + ProviderID: gitAuthConfig.ID, + UserID: apiKey.UserID, + }) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to get git auth link.", + Detail: err.Error(), + }) + return + } + + _, err = api.Database.InsertGitAuthLink(ctx, database.InsertGitAuthLinkParams{ + ProviderID: gitAuthConfig.ID, + UserID: apiKey.UserID, + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + OAuthAccessToken: state.Token.AccessToken, + OAuthRefreshToken: state.Token.RefreshToken, + OAuthExpiry: state.Token.Expiry, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to insert git auth link.", + Detail: err.Error(), + }) + return + } + } else { + err = api.Database.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{ + ProviderID: gitAuthConfig.ID, + UserID: apiKey.UserID, + UpdatedAt: database.Now(), + OAuthAccessToken: state.Token.AccessToken, + OAuthRefreshToken: state.Token.RefreshToken, + OAuthExpiry: state.Token.Expiry, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to update git auth link.", + Detail: err.Error(), + }) + return + } + } + + err = api.Pubsub.Publish("gitauth", []byte(fmt.Sprintf("%s|%s", gitAuthConfig.ID, apiKey.UserID))) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to publish auth update.", + Detail: err.Error(), + }) + return + } + + // This is a nicely rendered screen on the frontend + http.Redirect(rw, r, "/gitauth", http.StatusTemporaryRedirect) + } +} + // wsNetConn wraps net.Conn created by websocket.NetConn(). Cancel func // is called if a read or write error is encountered. type wsNetConn struct { diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 1b62fa5543aa7..b5b6ab1f0c7cd 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "net" + "net/http" + "regexp" "runtime" "strconv" "strings" @@ -13,12 +15,14 @@ import ( "time" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/agent" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" @@ -718,3 +722,216 @@ func TestWorkspaceAgentAppHealth(t *testing.T) { require.NoError(t, err) require.EqualValues(t, codersdk.WorkspaceAppHealthUnhealthy, metadata.Apps[1].Health) } + +// nolint:bodyclose +func TestWorkspaceAgentsGitAuth(t *testing.T) { + t.Parallel() + t.Run("NoMatchingConfig", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + GitAuthConfigs: []*gitauth.Config{}, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + _, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com", false) + var apiError *codersdk.Error + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusNotFound, apiError.StatusCode()) + }) + t.Run("ReturnsURL", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + GitAuthConfigs: []*gitauth.Config{{ + OAuth2Config: &oauth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.GitProviderGitHub, + }}, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + token, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", false) + require.NoError(t, err) + require.True(t, strings.HasSuffix(token.URL, fmt.Sprintf("/gitauth/%s", "github"))) + }) + t.Run("UnauthorizedCallback", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + GitAuthConfigs: []*gitauth.Config{{ + OAuth2Config: &oauth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.GitProviderGitHub, + }}, + }) + resp := gitAuthCallback(t, "github", client) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + t.Run("AuthorizedCallback", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + GitAuthConfigs: []*gitauth.Config{{ + OAuth2Config: &oauth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.GitProviderGitHub, + }}, + }) + _ = coderdtest.CreateFirstUser(t, client) + resp := gitAuthCallback(t, "github", client) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + location, err := resp.Location() + require.NoError(t, err) + require.Equal(t, "/gitauth", location.Path) + + // Callback again to simulate updating the token. + resp = gitAuthCallback(t, "github", client) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + }) + t.Run("FullFlow", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + GitAuthConfigs: []*gitauth.Config{{ + OAuth2Config: &oauth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.GitProviderGitHub, + }}, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + + token, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", false) + require.NoError(t, err) + require.NotEmpty(t, token.URL) + + // Start waiting for the token callback... + tokenChan := make(chan codersdk.WorkspaceAgentGitAuthResponse, 1) + go func() { + token, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", true) + assert.NoError(t, err) + tokenChan <- token + }() + + time.Sleep(250 * time.Millisecond) + + resp := gitAuthCallback(t, "github", client) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + token = <-tokenChan + require.Equal(t, "token", token.Username) + + token, err = agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", false) + require.NoError(t, err) + }) +} + +func gitAuthCallback(t *testing.T, id string, client *codersdk.Client) *http.Response { + client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + state := "somestate" + oauthURL, err := client.URL.Parse(fmt.Sprintf("/gitauth/%s/callback?code=asd&state=%s", id, state)) + require.NoError(t, err) + req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil) + require.NoError(t, err) + req.AddCookie(&http.Cookie{ + Name: codersdk.OAuth2StateKey, + Value: state, + }) + req.AddCookie(&http.Cookie{ + Name: codersdk.SessionTokenKey, + Value: client.SessionToken, + }) + res, err := client.HTTPClient.Do(req) + require.NoError(t, err) + t.Cleanup(func() { + _ = res.Body.Close() + }) + return res +} diff --git a/codersdk/deploymentconfig.go b/codersdk/deploymentconfig.go index a33f79718ffec..d9702f5f19941 100644 --- a/codersdk/deploymentconfig.go +++ b/codersdk/deploymentconfig.go @@ -11,33 +11,34 @@ import ( // DeploymentConfig is the central configuration for the coder server. type DeploymentConfig struct { - AccessURL *DeploymentConfigField[string] `json:"access_url" typescript:",notnull"` - WildcardAccessURL *DeploymentConfigField[string] `json:"wildcard_access_url" typescript:",notnull"` - Address *DeploymentConfigField[string] `json:"address" typescript:",notnull"` - AutobuildPollInterval *DeploymentConfigField[time.Duration] `json:"autobuild_poll_interval" typescript:",notnull"` - DERP *DERP `json:"derp" typescript:",notnull"` - Prometheus *PrometheusConfig `json:"prometheus" typescript:",notnull"` - Pprof *PprofConfig `json:"pprof" typescript:",notnull"` - ProxyTrustedHeaders *DeploymentConfigField[[]string] `json:"proxy_trusted_headers" typescript:",notnull"` - ProxyTrustedOrigins *DeploymentConfigField[[]string] `json:"proxy_trusted_origins" typescript:",notnull"` - CacheDirectory *DeploymentConfigField[string] `json:"cache_directory" typescript:",notnull"` - InMemoryDatabase *DeploymentConfigField[bool] `json:"in_memory_database" typescript:",notnull"` - ProvisionerDaemons *DeploymentConfigField[int] `json:"provisioner_daemons" typescript:",notnull"` - PostgresURL *DeploymentConfigField[string] `json:"pg_connection_url" typescript:",notnull"` - OAuth2 *OAuth2Config `json:"oauth2" typescript:",notnull"` - OIDC *OIDCConfig `json:"oidc" typescript:",notnull"` - Telemetry *TelemetryConfig `json:"telemetry" typescript:",notnull"` - TLS *TLSConfig `json:"tls" typescript:",notnull"` - TraceEnable *DeploymentConfigField[bool] `json:"trace_enable" typescript:",notnull"` - SecureAuthCookie *DeploymentConfigField[bool] `json:"secure_auth_cookie" typescript:",notnull"` - SSHKeygenAlgorithm *DeploymentConfigField[string] `json:"ssh_keygen_algorithm" typescript:",notnull"` - AutoImportTemplates *DeploymentConfigField[[]string] `json:"auto_import_templates" typescript:",notnull"` - MetricsCacheRefreshInterval *DeploymentConfigField[time.Duration] `json:"metrics_cache_refresh_interval" typescript:",notnull"` - AgentStatRefreshInterval *DeploymentConfigField[time.Duration] `json:"agent_stat_refresh_interval" typescript:",notnull"` - AuditLogging *DeploymentConfigField[bool] `json:"audit_logging" typescript:",notnull"` - BrowserOnly *DeploymentConfigField[bool] `json:"browser_only" typescript:",notnull"` - SCIMAPIKey *DeploymentConfigField[string] `json:"scim_api_key" typescript:",notnull"` - UserWorkspaceQuota *DeploymentConfigField[int] `json:"user_workspace_quota" typescript:",notnull"` + AccessURL *DeploymentConfigField[string] `json:"access_url" typescript:",notnull"` + WildcardAccessURL *DeploymentConfigField[string] `json:"wildcard_access_url" typescript:",notnull"` + Address *DeploymentConfigField[string] `json:"address" typescript:",notnull"` + AutobuildPollInterval *DeploymentConfigField[time.Duration] `json:"autobuild_poll_interval" typescript:",notnull"` + DERP *DERP `json:"derp" typescript:",notnull"` + GitAuth *DeploymentConfigField[[]GitAuthConfig] `json:"gitauth" typescript:",notnull"` + Prometheus *PrometheusConfig `json:"prometheus" typescript:",notnull"` + Pprof *PprofConfig `json:"pprof" typescript:",notnull"` + ProxyTrustedHeaders *DeploymentConfigField[[]string] `json:"proxy_trusted_headers" typescript:",notnull"` + ProxyTrustedOrigins *DeploymentConfigField[[]string] `json:"proxy_trusted_origins" typescript:",notnull"` + CacheDirectory *DeploymentConfigField[string] `json:"cache_directory" typescript:",notnull"` + InMemoryDatabase *DeploymentConfigField[bool] `json:"in_memory_database" typescript:",notnull"` + ProvisionerDaemons *DeploymentConfigField[int] `json:"provisioner_daemons" typescript:",notnull"` + PostgresURL *DeploymentConfigField[string] `json:"pg_connection_url" typescript:",notnull"` + OAuth2 *OAuth2Config `json:"oauth2" typescript:",notnull"` + OIDC *OIDCConfig `json:"oidc" typescript:",notnull"` + Telemetry *TelemetryConfig `json:"telemetry" typescript:",notnull"` + TLS *TLSConfig `json:"tls" typescript:",notnull"` + TraceEnable *DeploymentConfigField[bool] `json:"trace_enable" typescript:",notnull"` + SecureAuthCookie *DeploymentConfigField[bool] `json:"secure_auth_cookie" typescript:",notnull"` + SSHKeygenAlgorithm *DeploymentConfigField[string] `json:"ssh_keygen_algorithm" typescript:",notnull"` + AutoImportTemplates *DeploymentConfigField[[]string] `json:"auto_import_templates" typescript:",notnull"` + MetricsCacheRefreshInterval *DeploymentConfigField[time.Duration] `json:"metrics_cache_refresh_interval" typescript:",notnull"` + AgentStatRefreshInterval *DeploymentConfigField[time.Duration] `json:"agent_stat_refresh_interval" typescript:",notnull"` + AuditLogging *DeploymentConfigField[bool] `json:"audit_logging" typescript:",notnull"` + BrowserOnly *DeploymentConfigField[bool] `json:"browser_only" typescript:",notnull"` + SCIMAPIKey *DeploymentConfigField[string] `json:"scim_api_key" typescript:",notnull"` + UserWorkspaceQuota *DeploymentConfigField[int] `json:"user_workspace_quota" typescript:",notnull"` } type DERP struct { @@ -106,8 +107,18 @@ type TLSConfig struct { MinVersion *DeploymentConfigField[string] `json:"min_version" typescript:",notnull"` } +type GitAuthConfig struct { + ID string `json:"id"` + Type string `json:"type"` + ClientID string `json:"client_id"` + ClientSecret string `json:"-" yaml:"client_secret"` + AuthURL string `json:"auth_url"` + TokenURL string `json:"token_url"` + Regex string `json:"regex"` +} + type Flaggable interface { - string | bool | int | time.Duration | []string + string | time.Duration | bool | int | []string | []GitAuthConfig } type DeploymentConfigField[T Flaggable] struct { diff --git a/codersdk/features.go b/codersdk/features.go index 862411de62872..336c01cc9eda9 100644 --- a/codersdk/features.go +++ b/codersdk/features.go @@ -22,6 +22,7 @@ const ( FeatureWorkspaceQuota = "workspace_quota" FeatureTemplateRBAC = "template_rbac" FeatureHighAvailability = "high_availability" + FeatureMultipleGitAuth = "multiple_git_auth" ) var FeatureNames = []string{ @@ -32,6 +33,7 @@ var FeatureNames = []string{ FeatureWorkspaceQuota, FeatureTemplateRBAC, FeatureHighAvailability, + FeatureMultipleGitAuth, } type Feature struct { diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 666052c970190..b3c82772c2917 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/cookiejar" "net/netip" + "net/url" "strconv" "time" @@ -118,6 +119,10 @@ type PostWorkspaceAgentVersionRequest struct { // @typescript-ignore WorkspaceAgentMetadata type WorkspaceAgentMetadata struct { + // GitAuthConfigs stores the number of Git configurations + // the Coder deployment has. If this number is >0, we + // set up special configuration in the workspace. + GitAuthConfigs int `json:"git_auth_configs"` Apps []WorkspaceApp `json:"apps"` DERPMap *tailcfg.DERPMap `json:"derpmap"` EnvironmentVariables map[string]string `json:"environment_variables"` @@ -630,3 +635,43 @@ func (c *Client) AgentReportStats( return nil }), nil } + +// GitProvider is a constant that represents the +// type of providers that are supported within Coder. +// @typescript-ignore GitProvider +type GitProvider string + +const ( + GitProviderAzureDevops = "azure-devops" + GitProviderGitHub = "github" + GitProviderGitLab = "gitlab" + GitProviderBitBucket = "bitbucket" +) + +type WorkspaceAgentGitAuthResponse struct { + Username string `json:"username"` + Password string `json:"password"` + URL string `json:"url"` +} + +// WorkspaceAgentGitAuth submits a URL to fetch a GIT_ASKPASS username +// and password for. +// nolint:revive +func (c *Client) WorkspaceAgentGitAuth(ctx context.Context, gitURL string, listen bool) (WorkspaceAgentGitAuthResponse, error) { + reqURL := "/api/v2/workspaceagents/me/gitauth?url=" + url.QueryEscape(gitURL) + if listen { + reqURL += "&listen" + } + res, err := c.Request(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return WorkspaceAgentGitAuthResponse{}, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return WorkspaceAgentGitAuthResponse{}, readBodyAsError(res) + } + + var authResp WorkspaceAgentGitAuthResponse + return authResp, json.NewDecoder(res.Body).Decode(&authResp) +} diff --git a/enterprise/cli/features_test.go b/enterprise/cli/features_test.go index 78b94a6509526..1708807ddf1dc 100644 --- a/enterprise/cli/features_test.go +++ b/enterprise/cli/features_test.go @@ -57,22 +57,10 @@ func TestFeaturesList(t *testing.T) { var entitlements codersdk.Entitlements err := json.Unmarshal(buf.Bytes(), &entitlements) require.NoError(t, err, "unmarshal JSON output") - assert.Len(t, entitlements.Features, 7) assert.Empty(t, entitlements.Warnings) - assert.Equal(t, codersdk.EntitlementNotEntitled, - entitlements.Features[codersdk.FeatureUserLimit].Entitlement) - assert.Equal(t, codersdk.EntitlementNotEntitled, - entitlements.Features[codersdk.FeatureAuditLog].Entitlement) - assert.Equal(t, codersdk.EntitlementNotEntitled, - entitlements.Features[codersdk.FeatureBrowserOnly].Entitlement) - assert.Equal(t, codersdk.EntitlementNotEntitled, - entitlements.Features[codersdk.FeatureWorkspaceQuota].Entitlement) - assert.Equal(t, codersdk.EntitlementNotEntitled, - entitlements.Features[codersdk.FeatureTemplateRBAC].Entitlement) - assert.Equal(t, codersdk.EntitlementNotEntitled, - entitlements.Features[codersdk.FeatureSCIM].Entitlement) - assert.Equal(t, codersdk.EntitlementNotEntitled, - entitlements.Features[codersdk.FeatureHighAvailability].Entitlement) + for _, featureName := range codersdk.FeatureNames { + assert.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[featureName].Entitlement) + } assert.False(t, entitlements.HasLicense) assert.False(t, entitlements.Experimental) }) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index b9d095883824e..b65dd4271518a 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -211,12 +211,13 @@ func (api *API) updateEntitlements(ctx context.Context) error { api.entitlementsMu.Lock() defer api.entitlementsMu.Unlock() - entitlements, err := license.Entitlements(ctx, api.Database, api.Logger, len(api.replicaManager.All()), api.Keys, map[string]bool{ + entitlements, err := license.Entitlements(ctx, api.Database, api.Logger, len(api.replicaManager.All()), len(api.GitAuthConfigs), api.Keys, map[string]bool{ codersdk.FeatureAuditLog: api.AuditLogging, codersdk.FeatureBrowserOnly: api.BrowserOnly, codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0, codersdk.FeatureWorkspaceQuota: api.UserWorkspaceQuota != 0, codersdk.FeatureHighAvailability: api.DERPServerRelayAddress != "", + codersdk.FeatureMultipleGitAuth: len(api.GitAuthConfigs) > 1, codersdk.FeatureTemplateRBAC: api.RBAC, }) if err != nil { diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index a8595b5bc6ede..341cb87dff1ec 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -113,6 +113,7 @@ type LicenseOptions struct { WorkspaceQuota bool TemplateRBAC bool HighAvailability bool + MultipleGitAuth bool } // AddLicense generates a new license with the options provided and inserts it. @@ -158,6 +159,11 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { rbacEnabled = 1 } + multipleGitAuth := int64(0) + if options.MultipleGitAuth { + multipleGitAuth = 1 + } + c := &license.Claims{ RegisteredClaims: jwt.RegisteredClaims{ Issuer: "test@testing.test", @@ -179,6 +185,7 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { WorkspaceQuota: workspaceQuota, HighAvailability: highAvailability, TemplateRBAC: rbacEnabled, + MultipleGitAuth: multipleGitAuth, }, } tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index c5bb689db65a9..80f502f527f2d 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -22,6 +22,7 @@ func Entitlements( db database.Store, logger slog.Logger, replicaCount int, + gitAuthCount int, keys map[string]ed25519.PublicKey, enablements map[string]bool, ) (codersdk.Entitlements, error) { @@ -116,6 +117,12 @@ func Entitlements( Enabled: enablements[codersdk.FeatureTemplateRBAC], } } + if claims.Features.MultipleGitAuth > 0 { + entitlements.Features[codersdk.FeatureMultipleGitAuth] = codersdk.Feature{ + Entitlement: entitlement, + Enabled: true, + } + } if claims.AllFeatures { allFeatures = true } @@ -150,6 +157,10 @@ func Entitlements( if featureName == codersdk.FeatureHighAvailability { continue } + // Multiple Git auth has it's own warnings based on the number configured! + if featureName == codersdk.FeatureMultipleGitAuth { + continue + } feature := entitlements.Features[featureName] if !feature.Enabled { continue @@ -173,10 +184,10 @@ func Entitlements( switch feature.Entitlement { case codersdk.EntitlementNotEntitled: if entitlements.HasLicense { - entitlements.Errors = append(entitlements.Warnings, + entitlements.Errors = append(entitlements.Errors, "You have multiple replicas but your license is not entitled to high availability. You will be unable to connect to workspaces.") } else { - entitlements.Errors = append(entitlements.Warnings, + entitlements.Errors = append(entitlements.Errors, "You have multiple replicas but high availability is an Enterprise feature. You will be unable to connect to workspaces.") } case codersdk.EntitlementGracePeriod: @@ -185,6 +196,27 @@ func Entitlements( } } + if gitAuthCount > 1 { + feature := entitlements.Features[codersdk.FeatureMultipleGitAuth] + + switch feature.Entitlement { + case codersdk.EntitlementNotEntitled: + if entitlements.HasLicense { + entitlements.Errors = append(entitlements.Errors, + "You have multiple Git authorizations configured but your license is limited at one.", + ) + } else { + entitlements.Errors = append(entitlements.Errors, + "You have multiple Git authorizations configured but this is an Enterprise feature. Reduce to one.", + ) + } + case codersdk.EntitlementGracePeriod: + entitlements.Warnings = append(entitlements.Warnings, + "You have multiple Git authorizations configured but your license is expired. Reduce to one.", + ) + } + } + for _, featureName := range codersdk.FeatureNames { feature := entitlements.Features[featureName] if feature.Entitlement == codersdk.EntitlementNotEntitled { @@ -219,6 +251,7 @@ type Features struct { WorkspaceQuota int64 `json:"workspace_quota"` TemplateRBAC int64 `json:"template_rbac"` HighAvailability int64 `json:"high_availability"` + MultipleGitAuth int64 `json:"multiple_git_auth"` } type Claims struct { diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index a47dd83c98ab1..a1b26f8dab9e4 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -26,12 +26,13 @@ func TestEntitlements(t *testing.T) { codersdk.FeatureWorkspaceQuota: true, codersdk.FeatureHighAvailability: true, codersdk.FeatureTemplateRBAC: true, + codersdk.FeatureMultipleGitAuth: true, } t.Run("Defaults", func(t *testing.T) { t.Parallel() db := databasefake.New() - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, all) require.NoError(t, err) require.False(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -47,7 +48,7 @@ func TestEntitlements(t *testing.T) { JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{}), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, map[string]bool{}) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, map[string]bool{}) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -68,10 +69,11 @@ func TestEntitlements(t *testing.T) { WorkspaceQuota: true, HighAvailability: true, TemplateRBAC: true, + MultipleGitAuth: true, }), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, map[string]bool{}) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, map[string]bool{}) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -96,7 +98,7 @@ func TestEntitlements(t *testing.T) { }), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, all) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -107,6 +109,9 @@ func TestEntitlements(t *testing.T) { if featureName == codersdk.FeatureHighAvailability { continue } + if featureName == codersdk.FeatureMultipleGitAuth { + continue + } niceName := strings.Title(strings.ReplaceAll(featureName, "_", " ")) require.Equal(t, codersdk.EntitlementGracePeriod, entitlements.Features[featureName].Entitlement) require.Contains(t, entitlements.Warnings, fmt.Sprintf("%s is enabled but your license for this feature is expired.", niceName)) @@ -119,7 +124,7 @@ func TestEntitlements(t *testing.T) { JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{}), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, all) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -130,6 +135,9 @@ func TestEntitlements(t *testing.T) { if featureName == codersdk.FeatureHighAvailability { continue } + if featureName == codersdk.FeatureMultipleGitAuth { + continue + } niceName := strings.Title(strings.ReplaceAll(featureName, "_", " ")) // Ensures features that are not entitled are properly disabled. require.False(t, entitlements.Features[featureName].Enabled) @@ -152,7 +160,7 @@ func TestEntitlements(t *testing.T) { }), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, map[string]bool{}) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, map[string]bool{}) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Contains(t, entitlements.Warnings, "Your deployment has 2 active users but is only licensed for 1.") @@ -174,7 +182,7 @@ func TestEntitlements(t *testing.T) { }), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, map[string]bool{}) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, map[string]bool{}) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Empty(t, entitlements.Warnings) @@ -197,7 +205,7 @@ func TestEntitlements(t *testing.T) { }), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, map[string]bool{}) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, map[string]bool{}) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -212,7 +220,7 @@ func TestEntitlements(t *testing.T) { AllFeatures: true, }), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, all) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -228,7 +236,7 @@ func TestEntitlements(t *testing.T) { t.Run("MultipleReplicasNoLicense", func(t *testing.T) { t.Parallel() db := databasefake.New() - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 2, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 2, 1, coderdenttest.Keys, all) require.NoError(t, err) require.False(t, entitlements.HasLicense) require.Len(t, entitlements.Errors, 1) @@ -244,7 +252,7 @@ func TestEntitlements(t *testing.T) { AuditLog: true, }), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 2, coderdenttest.Keys, map[string]bool{ + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 2, 1, coderdenttest.Keys, map[string]bool{ codersdk.FeatureHighAvailability: true, }) require.NoError(t, err) @@ -264,7 +272,7 @@ func TestEntitlements(t *testing.T) { }), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 2, coderdenttest.Keys, map[string]bool{ + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 2, 1, coderdenttest.Keys, map[string]bool{ codersdk.FeatureHighAvailability: true, }) require.NoError(t, err) @@ -272,4 +280,52 @@ func TestEntitlements(t *testing.T) { require.Len(t, entitlements.Warnings, 1) require.Equal(t, "You have multiple replicas but your license for high availability is expired. Reduce to one replica or workspace connections will stop working.", entitlements.Warnings[0]) }) + + t.Run("MultipleGitAuthNoLicense", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 2, coderdenttest.Keys, all) + require.NoError(t, err) + require.False(t, entitlements.HasLicense) + require.Len(t, entitlements.Errors, 1) + require.Equal(t, "You have multiple Git authorizations configured but this is an Enterprise feature. Reduce to one.", entitlements.Errors[0]) + }) + + t.Run("MultipleGitAuthNotEntitled", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + db.InsertLicense(context.Background(), database.InsertLicenseParams{ + Exp: time.Now().Add(time.Hour), + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + AuditLog: true, + }), + }) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 2, coderdenttest.Keys, map[string]bool{ + codersdk.FeatureMultipleGitAuth: true, + }) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + require.Len(t, entitlements.Errors, 1) + require.Equal(t, "You have multiple Git authorizations configured but your license is limited at one.", entitlements.Errors[0]) + }) + + t.Run("MultipleGitAuthGrace", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + db.InsertLicense(context.Background(), database.InsertLicenseParams{ + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + MultipleGitAuth: true, + GraceAt: time.Now().Add(-time.Hour), + ExpiresAt: time.Now().Add(time.Hour), + }), + Exp: time.Now().Add(time.Hour), + }) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 2, coderdenttest.Keys, map[string]bool{ + codersdk.FeatureMultipleGitAuth: true, + }) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + require.Len(t, entitlements.Warnings, 1) + require.Equal(t, "You have multiple Git authorizations configured but your license is expired. Reduce to one.", entitlements.Warnings[0]) + }) } diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go index aa4dddf1fd5f1..b4254bfccf4f5 100644 --- a/enterprise/coderd/licenses_test.go +++ b/enterprise/coderd/licenses_test.go @@ -108,6 +108,7 @@ func TestGetLicense(t *testing.T) { codersdk.FeatureWorkspaceQuota: json.Number("0"), codersdk.FeatureHighAvailability: json.Number("0"), codersdk.FeatureTemplateRBAC: json.Number("1"), + codersdk.FeatureMultipleGitAuth: json.Number("0"), }, licenses[0].Claims["features"]) assert.Equal(t, int32(2), licenses[1].ID) assert.Equal(t, "testing2", licenses[1].Claims["account_id"]) @@ -120,6 +121,7 @@ func TestGetLicense(t *testing.T) { codersdk.FeatureWorkspaceQuota: json.Number("0"), codersdk.FeatureHighAvailability: json.Number("0"), codersdk.FeatureTemplateRBAC: json.Number("0"), + codersdk.FeatureMultipleGitAuth: json.Number("0"), }, licenses[1].Claims["features"]) }) } diff --git a/go.mod b/go.mod index ca574b79b18e8..7ff098d4418eb 100644 --- a/go.mod +++ b/go.mod @@ -170,6 +170,7 @@ require ( ) require ( + github.com/adrg/xdg v0.4.0 // indirect github.com/fsnotify/fsnotify v1.5.4 // indirect github.com/magiconair/properties v1.8.6 // indirect github.com/pelletier/go-toml v1.9.5 // indirect diff --git a/go.sum b/go.sum index 8e742accf1dd7..8d52ecaf4ebf8 100644 --- a/go.sum +++ b/go.sum @@ -159,6 +159,8 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= +github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= +github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index feecf7dd6ab72..ca9191030c545 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -74,12 +74,16 @@ const GeneralSettingsPage = lazy( const SecuritySettingsPage = lazy( () => import("./pages/DeploySettingsPage/SecuritySettingsPage"), ) -const AuthSettingsPage = lazy( - () => import("./pages/DeploySettingsPage/AuthSettingsPage"), +const UserAuthSettingsPage = lazy( + () => import("./pages/DeploySettingsPage/UserAuthSettingsPage"), +) +const GitAuthSettingsPage = lazy( + () => import("./pages/DeploySettingsPage/GitAuthSettingsPage"), ) const NetworkSettingsPage = lazy( () => import("./pages/DeploySettingsPage/NetworkSettingsPage"), ) +const GitAuthPage = lazy(() => import("./pages/GitAuthPage/GitAuthPage")) export const AppRouter: FC = () => { const xServices = useContext(XServiceContext) @@ -112,6 +116,14 @@ export const AppRouter: FC = () => { } /> + + + + } + /> { } /> + + + + + + + } + /> + - + diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index e9345e9ffafe2..507ab688ddabb 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -280,6 +280,7 @@ export interface DeploymentConfig { readonly address: DeploymentConfigField readonly autobuild_poll_interval: DeploymentConfigField readonly derp: DERP + readonly gitauth: DeploymentConfigField readonly prometheus: PrometheusConfig readonly pprof: PprofConfig readonly proxy_trusted_headers: DeploymentConfigField @@ -345,6 +346,16 @@ export interface GetAppHostResponse { readonly host: string } +// From codersdk/deploymentconfig.go +export interface GitAuthConfig { + readonly id: string + readonly type: string + readonly client_id: string + readonly auth_url: string + readonly token_url: string + readonly regex: string +} + // From codersdk/gitsshkey.go export interface GitSSHKey { readonly user_id: string @@ -780,6 +791,13 @@ export interface WorkspaceAgent { readonly latency?: Record } +// From codersdk/workspaceagents.go +export interface WorkspaceAgentGitAuthResponse { + readonly username: string + readonly password: string + readonly url: string +} + // From codersdk/workspaceagents.go export interface WorkspaceAgentInstanceMetadata { readonly jail_orchestrator: string @@ -997,4 +1015,4 @@ export type WorkspaceStatus = export type WorkspaceTransition = "delete" | "start" | "stop" // From codersdk/deploymentconfig.go -export type Flaggable = string | boolean | number | string[] +export type Flaggable = string | number | boolean | string[] | GitAuthConfig[] diff --git a/site/src/components/AlertBanner/alertTypes.ts b/site/src/components/AlertBanner/alertTypes.ts index 154c2c9a2cdab..638f73748d441 100644 --- a/site/src/components/AlertBanner/alertTypes.ts +++ b/site/src/components/AlertBanner/alertTypes.ts @@ -1,7 +1,7 @@ import { ApiError } from "api/errors" import { ReactElement } from "react" -export type Severity = "warning" | "error" +export type Severity = "warning" | "error" | "info" export interface AlertBannerProps { severity: Severity diff --git a/site/src/components/AlertBanner/severityConstants.tsx b/site/src/components/AlertBanner/severityConstants.tsx index 0c759fb83921c..fcc5e3cc0c89f 100644 --- a/site/src/components/AlertBanner/severityConstants.tsx +++ b/site/src/components/AlertBanner/severityConstants.tsx @@ -1,5 +1,6 @@ import ReportProblemOutlinedIcon from "@material-ui/icons/ReportProblemOutlined" import ErrorOutlineOutlinedIcon from "@material-ui/icons/ErrorOutlineOutlined" +import InfoOutlinedIcon from "@material-ui/icons/InfoOutlined" import { colors } from "theme/colors" import { Severity } from "./alertTypes" import { ReactElement } from "react" @@ -26,4 +27,10 @@ export const severityConstants: Record< /> ), }, + info: { + color: colors.blue[7], + icon: ( + + ), + }, } diff --git a/site/src/components/DeploySettingsLayout/OptionsTable.tsx b/site/src/components/DeploySettingsLayout/OptionsTable.tsx index e613b0348a634..4e793629261d9 100644 --- a/site/src/components/DeploySettingsLayout/OptionsTable.tsx +++ b/site/src/components/DeploySettingsLayout/OptionsTable.tsx @@ -37,7 +37,7 @@ const OptionsTable: React.FC<{ - {option.value} + {option.value.toString()} ) diff --git a/site/src/components/DeploySettingsLayout/Sidebar.tsx b/site/src/components/DeploySettingsLayout/Sidebar.tsx index ad195acbdcd25..42d7ef45d3ac6 100644 --- a/site/src/components/DeploySettingsLayout/Sidebar.tsx +++ b/site/src/components/DeploySettingsLayout/Sidebar.tsx @@ -3,10 +3,18 @@ import LaunchOutlined from "@material-ui/icons/LaunchOutlined" import LockRounded from "@material-ui/icons/LockRounded" import Globe from "@material-ui/icons/Public" import VpnKeyOutlined from "@material-ui/icons/VpnKeyOutlined" +import { useSelector } from "@xstate/react" +import { GitIcon } from "components/Icons/GitIcon" import { Stack } from "components/Stack/Stack" -import React, { ElementType, PropsWithChildren, ReactNode } from "react" +import React, { + ElementType, + PropsWithChildren, + ReactNode, + useContext, +} from "react" import { NavLink } from "react-router-dom" import { combineClasses } from "util/combineClasses" +import { XServiceContext } from "../../xServices/StateContext" const SidebarNavItem: React.FC< PropsWithChildren<{ href: string; icon: ReactNode }> @@ -39,6 +47,11 @@ const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({ export const Sidebar: React.FC = () => { const styles = useStyles() + const xServices = useContext(XServiceContext) + const experimental = useSelector( + xServices.entitlementsXService, + (state) => state.context.entitlements.experimental, + ) return (