mirror of https://github.com/coder/coder.git
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:
parent
585045b359
commit
eec406b739
|
@ -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",
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
})
|
||||
}
|
11
cli/login.go
11
cli/login.go
|
@ -285,5 +285,16 @@ func openURL(cmd *cobra.Command, urlToOpen string) error {
|
|||
return exec.Command("cmd.exe", "/c", "start", strings.ReplaceAll(urlToOpen, "&", "^&")).Start()
|
||||
}
|
||||
|
||||
browserEnv := os.Getenv("BROWSER")
|
||||
if browserEnv != "" {
|
||||
browserSh := fmt.Sprintf("%s '%s'", browserEnv, urlToOpen)
|
||||
cmd := exec.CommandContext(cmd.Context(), "sh", "-c", browserSh)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to run %v (out: %q): %w", cmd.Args, out, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return browser.OpenURL(urlToOpen)
|
||||
}
|
||||
|
|
26
cli/root.go
26
cli/root.go
|
@ -25,6 +25,7 @@ import (
|
|||
"github.com/coder/coder/cli/config"
|
||||
"github.com/coder/coder/cli/deployment"
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/coderd/gitauth"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
|
@ -108,13 +109,33 @@ func AGPL() []*cobra.Command {
|
|||
}
|
||||
|
||||
func Root(subcommands []*cobra.Command) *cobra.Command {
|
||||
// The GIT_ASKPASS environment variable must point at
|
||||
// a binary with no arguments. To prevent writing
|
||||
// cross-platform scripts to invoke the Coder binary
|
||||
// with a `gitaskpass` subcommand, we override the entrypoint
|
||||
// to check if the command was invoked.
|
||||
isGitAskpass := false
|
||||
|
||||
fmtLong := `Coder %s — A tool for provisioning self-hosted development environments with Terraform.
|
||||
`
|
||||
cmd := &cobra.Command{
|
||||
Use: "coder",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
Long: fmt.Sprintf(fmtLong, buildinfo.Version()),
|
||||
Long: fmt.Sprintf(fmtLong, buildinfo.Version()),
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if gitauth.CheckCommand(args, os.Environ()) {
|
||||
isGitAskpass = true
|
||||
return nil
|
||||
}
|
||||
return cobra.NoArgs(cmd, args)
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if isGitAskpass {
|
||||
return gitAskpass().RunE(cmd, args)
|
||||
}
|
||||
return cmd.Help()
|
||||
},
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
if cliflag.IsSetBool(cmd, varNoVersionCheck) &&
|
||||
cliflag.IsSetBool(cmd, varNoFeatureWarning) {
|
||||
|
@ -134,6 +155,9 @@ func Root(subcommands []*cobra.Command) *cobra.Command {
|
|||
if cmd.Name() == "login" || cmd.Name() == "server" || cmd.Name() == "agent" || cmd.Name() == "gitssh" {
|
||||
return
|
||||
}
|
||||
if isGitAskpass {
|
||||
return
|
||||
}
|
||||
|
||||
client, err := CreateClient(cmd)
|
||||
// If we are unable to create a client, presumably the subcommand will fail as well
|
||||
|
|
|
@ -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!")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE git_auth_links;
|
|
@ -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)
|
||||
);
|
|
@ -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"`
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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", ""),
|
||||
)...,
|
||||
)
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package gitauth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOAuthJWTConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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"])
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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), "_", "-")
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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])
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
1
go.mod
|
@ -170,6 +170,7 @@ require (
|
|||
)
|
||||
|
||||
require (
|
||||
github.com/adrg/xdg v0.4.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.5.4 // indirect
|
||||
github.com/magiconair/properties v1.8.6 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
|
|
2
go.sum
2
go.sum
|
@ -159,6 +159,8 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko
|
|||
github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
|
||||
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
|
||||
github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
|
||||
github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls=
|
||||
github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E=
|
||||
github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
|
||||
github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
|
||||
github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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[]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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] }} />
|
||||
),
|
||||
},
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ const OptionsTable: React.FC<{
|
|||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<OptionValue>{option.value}</OptionValue>
|
||||
<OptionValue>{option.value.toString()}</OptionValue>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
|
|
|
@ -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} />}
|
||||
|
|
|
@ -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>
|
||||
)
|
|
@ -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
|
|
@ -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
|
|
@ -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 = {}
|
|
@ -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),
|
||||
},
|
||||
}))
|
Binary file not shown.
Loading…
Reference in New Issue