feat: Add Git auth for GitHub, GitLab, Azure DevOps, and BitBucket (#4670)

* Add scaffolding

* Move migration

* Add endpoints for gitauth

* Add configuration files and tests!

* Update typesgen

* Convert configuration format for git auth

* Fix unclosed database conn

* Add overriding VS Code configuration

* Fix Git screen

* Write VS Code special configuration if providers exist

* Enable automatic cloning from VS Code

* Add tests for gitaskpass

* Fix feature visibiliy

* Add banner for too many configurations

* Fix update loop for oauth token

* Jon comments

* Add deployment config page
This commit is contained in:
Kyle Carberry 2022-10-24 19:46:24 -05:00 committed by GitHub
parent 585045b359
commit eec406b739
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
62 changed files with 2211 additions and 88 deletions

View File

@ -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",

View File

@ -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()

View File

@ -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 {

View File

@ -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()

25
cli/config/server.yaml Normal file
View File

@ -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

View File

@ -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_<index>_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))
}

View File

@ -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) {

83
cli/gitaskpass.go Normal file
View File

@ -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
},
}
}

97
cli/gitaskpass_test.go Normal file
View File

@ -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")
})
}

View File

@ -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)
}

View File

@ -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

View File

@ -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!")

View File

@ -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)

View File

@ -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},

View File

@ -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,

View File

@ -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
}

View File

@ -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);

View File

@ -0,0 +1 @@
DROP TABLE git_auth_links;

View File

@ -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)
);

View File

@ -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"`

View File

@ -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)

View File

@ -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

View File

@ -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;

View File

@ -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);

70
coderd/gitauth/askpass.go Normal file
View File

@ -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
}

View File

@ -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)
})
}
}

103
coderd/gitauth/config.go Normal file
View File

@ -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
}

View File

@ -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)
})
}
}

83
coderd/gitauth/oauth.go Normal file
View File

@ -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", ""),
)...,
)
}

View File

@ -0,0 +1,9 @@
package gitauth_test
import (
"testing"
)
func TestOAuthJWTConfig(t *testing.T) {
t.Parallel()
}

81
coderd/gitauth/vscode.go Normal file
View File

@ -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
}

View File

@ -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"])
}
})
}

View File

@ -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)

View File

@ -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), "_", "-")

View File

@ -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 {

View File

@ -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.

View File

@ -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) {

View File

@ -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 {

View File

@ -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
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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)
}

View File

@ -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)
})

View File

@ -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 {

View File

@ -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)

View File

@ -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 {

View File

@ -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])
})
}

View File

@ -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"])
})
}

1
go.mod
View File

@ -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

2
go.sum
View File

@ -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=

View File

@ -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 = () => {
</RequireAuth>
}
/>
<Route
path="gitauth"
element={
<RequireAuth>
<GitAuthPage />
</RequireAuth>
}
/>
<Route path="workspaces">
<Route
@ -294,14 +306,28 @@ export const AppRouter: FC = () => {
}
/>
<Route
path="auth"
path="userauth"
element={
<AuthAndFrame>
<RequirePermission
isFeatureVisible={Boolean(permissions?.viewDeploymentConfig)}
>
<DeploySettingsLayout>
<AuthSettingsPage />
<UserAuthSettingsPage />
</DeploySettingsLayout>
</RequirePermission>
</AuthAndFrame>
}
/>
<Route
path="gitauth"
element={
<AuthAndFrame>
<RequirePermission
isFeatureVisible={Boolean(permissions?.viewDeploymentConfig)}
>
<DeploySettingsLayout>
<GitAuthSettingsPage />
</DeploySettingsLayout>
</RequirePermission>
</AuthAndFrame>

View File

@ -280,6 +280,7 @@ export interface DeploymentConfig {
readonly address: DeploymentConfigField<string>
readonly autobuild_poll_interval: DeploymentConfigField<number>
readonly derp: DERP
readonly gitauth: DeploymentConfigField<GitAuthConfig[]>
readonly prometheus: PrometheusConfig
readonly pprof: PprofConfig
readonly proxy_trusted_headers: DeploymentConfigField<string[]>
@ -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<string, DERPRegion>
}
// 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[]

View File

@ -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

View File

@ -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: (
<InfoOutlinedIcon fontSize="small" style={{ color: colors.blue[7] }} />
),
},
}

View File

@ -37,7 +37,7 @@ const OptionsTable: React.FC<{
</TableCell>
<TableCell>
<OptionValue>{option.value}</OptionValue>
<OptionValue>{option.value.toString()}</OptionValue>
</TableCell>
</TableRow>
)

View File

@ -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 (
<nav className={styles.sidebar}>
@ -49,11 +62,19 @@ export const Sidebar: React.FC = () => {
General
</SidebarNavItem>
<SidebarNavItem
href="../auth"
href="../userauth"
icon={<SidebarNavItemIcon icon={VpnKeyOutlined} />}
>
Authentication
User Authentication
</SidebarNavItem>
{experimental && (
<SidebarNavItem
href="../gitauth"
icon={<SidebarNavItemIcon icon={GitIcon} />}
>
Git Authentication
</SidebarNavItem>
)}
<SidebarNavItem
href="../network"
icon={<SidebarNavItemIcon icon={Globe} />}

View File

@ -0,0 +1,7 @@
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon"
export const GitIcon: typeof SvgIcon = (props: SvgIconProps) => (
<SvgIcon {...props} viewBox="0 0 96 96">
<path d="M92.71 44.408 52.591 4.291c-2.31-2.311-6.057-2.311-8.369 0l-8.33 8.332L46.459 23.19c2.456-.83 5.272-.273 7.229 1.685 1.969 1.97 2.521 4.81 1.67 7.275l10.186 10.185c2.465-.85 5.307-.3 7.275 1.671 2.75 2.75 2.75 7.206 0 9.958-2.752 2.751-7.208 2.751-9.961 0-2.068-2.07-2.58-5.11-1.531-7.658l-9.5-9.499v24.997c.67.332 1.303.774 1.861 1.332 2.75 2.75 2.75 7.206 0 9.959-2.75 2.749-7.209 2.749-9.957 0-2.75-2.754-2.75-7.21 0-9.959.68-.679 1.467-1.193 2.307-1.537v-25.23c-.84-.344-1.625-.853-2.307-1.537-2.083-2.082-2.584-5.14-1.516-7.698L31.798 16.715 4.288 44.222c-2.311 2.313-2.311 6.06 0 8.371l40.121 40.118c2.31 2.311 6.056 2.311 8.369 0L92.71 52.779c2.311-2.311 2.311-6.06 0-8.371z" />
</SvgIcon>
)

View File

@ -0,0 +1,109 @@
import { makeStyles } from "@material-ui/core/styles"
import Table from "@material-ui/core/Table"
import TableBody from "@material-ui/core/TableBody"
import TableCell from "@material-ui/core/TableCell"
import TableContainer from "@material-ui/core/TableContainer"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { EnterpriseBadge } from "components/DeploySettingsLayout/Badges"
import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout"
import { Header } from "components/DeploySettingsLayout/Header"
import React from "react"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "util/page"
const GitAuthSettingsPage: React.FC = () => {
const styles = useStyles()
const { deploymentConfig: deploymentConfig } = useDeploySettings()
return (
<>
<Helmet>
<title>{pageTitle("Git Authentication Settings")}</title>
</Helmet>
<Header
title="Git Authentication"
description="Coder integrates with GitHub, GitLab, BitBucket, and Azure Repos to authenticate developers with your Git provider."
docsHref="https://coder.com/docs/coder-oss/latest/admin/git"
/>
<video
autoPlay
muted
loop
playsInline
src="/gitauth.mp4"
style={{
maxWidth: "100%",
borderRadius: 4,
}}
/>
<div className={styles.description}>
<AlertBanner
severity="info"
text="Integrating with multiple Git providers is an Enterprise feature."
actions={[<EnterpriseBadge key="enterprise" />]}
/>
</div>
<TableContainer>
<Table className={styles.table}>
<TableHead>
<TableRow>
<TableCell width="25%">Type</TableCell>
<TableCell width="25%">Client ID</TableCell>
<TableCell width="25%">Match</TableCell>
</TableRow>
</TableHead>
<TableBody>
{deploymentConfig.gitauth.value.length === 0 && (
<TableRow>
<TableCell colSpan={999}>
<div className={styles.empty}>
No providers have been configured!
</div>
</TableCell>
</TableRow>
)}
{deploymentConfig.gitauth.value.map((git) => {
const name = git.id || git.type
return (
<TableRow key={name}>
<TableCell>{name}</TableCell>
<TableCell>{git.client_id}</TableCell>
<TableCell>{git.regex || "Not Set"}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</TableContainer>
</>
)
}
const useStyles = makeStyles((theme) => ({
table: {
"& td": {
paddingTop: theme.spacing(3),
paddingBottom: theme.spacing(3),
},
"& td:last-child, & th:last-child": {
paddingLeft: theme.spacing(4),
},
},
description: {
marginTop: theme.spacing(3),
marginBottom: theme.spacing(3),
},
empty: {
textAlign: "center",
},
}))
export default GitAuthSettingsPage

View File

@ -11,18 +11,18 @@ import React from "react"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "util/page"
const AuthSettingsPage: React.FC = () => {
const UserAuthSettingsPage: React.FC = () => {
const { deploymentConfig: deploymentConfig } = useDeploySettings()
return (
<>
<Helmet>
<title>{pageTitle("Authentication Settings")}</title>
<title>{pageTitle("User Authentication Settings")}</title>
</Helmet>
<Stack direction="column" spacing={6}>
<div>
<Header title="Authentication" />
<Header title="User Authentication" />
<Header
title="Login with OpenID Connect"
@ -82,4 +82,4 @@ const AuthSettingsPage: React.FC = () => {
)
}
export default AuthSettingsPage
export default UserAuthSettingsPage

View File

@ -0,0 +1,12 @@
import { ComponentMeta, Story } from "@storybook/react"
import GitAuthPage from "./GitAuthPage"
export default {
title: "pages/GitAuthPage",
component: GitAuthPage,
} as ComponentMeta<typeof GitAuthPage>
const Template: Story = (args) => <GitAuthPage {...args} />
export const Default = Template.bind({})
Default.args = {}

View File

@ -0,0 +1,60 @@
import Button from "@material-ui/core/Button"
import { makeStyles } from "@material-ui/core/styles"
import { SignInLayout } from "components/SignInLayout/SignInLayout"
import { Welcome } from "components/Welcome/Welcome"
import React from "react"
import { Link as RouterLink } from "react-router-dom"
const GitAuthPage: React.FC = () => {
const styles = useStyles()
return (
<SignInLayout>
<Welcome message="Authenticated with Git!" />
<p className={styles.text}>
Your Git authentication token will be refreshed to keep you signed in.
</p>
<div className={styles.links}>
<Button
component={RouterLink}
size="large"
to="/workspaces"
fullWidth
variant="outlined"
>
Go to workspaces
</Button>
</div>
</SignInLayout>
)
}
export default GitAuthPage
const useStyles = makeStyles((theme) => ({
title: {
fontSize: theme.spacing(4),
fontWeight: 400,
lineHeight: "140%",
margin: 0,
},
text: {
fontSize: 16,
color: theme.palette.text.secondary,
marginBottom: theme.spacing(4),
textAlign: "center",
lineHeight: "160%",
},
lineBreak: {
whiteSpace: "nowrap",
},
links: {
display: "flex",
justifyContent: "flex-end",
paddingTop: theme.spacing(1),
},
}))

BIN
site/static/gitauth.mp4 Normal file

Binary file not shown.