From 3dc1f008803306d8b0271eea0e7050c47744e6be Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 17 Oct 2022 17:53:41 +0000 Subject: [PATCH 01/17] Add scaffolding --- cli/gitaskpass/gitaskpass.go | 70 ++++++++++++++++++ cli/gitaskpass/gitaskpass_test.go | 72 +++++++++++++++++++ coderd/database/dump.sql | 10 +++ .../migrations/000062_gitprovider.down.sql | 1 + .../migrations/000062_gitprovider.up.sql | 9 +++ coderd/database/models.go | 10 +++ 6 files changed, 172 insertions(+) create mode 100644 cli/gitaskpass/gitaskpass.go create mode 100644 cli/gitaskpass/gitaskpass_test.go create mode 100644 coderd/database/migrations/000062_gitprovider.down.sql create mode 100644 coderd/database/migrations/000062_gitprovider.up.sql diff --git a/cli/gitaskpass/gitaskpass.go b/cli/gitaskpass/gitaskpass.go new file mode 100644 index 0000000000000..cb3e758f6aa3d --- /dev/null +++ b/cli/gitaskpass/gitaskpass.go @@ -0,0 +1,70 @@ +package gitaskpass + +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 +} + +// Parse 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 Parse(prompt string) (user string, host string, err error) { + parts := strings.Split(prompt, " ") + if len(parts) < 3 { + return "", "", xerrors.Errorf("askpass prompt must contain 3 words; got %d: %q", len(parts), prompt) + } + + switch parts[0] { + case "Username", "Password": + default: + return "", "", xerrors.Errorf("unknown prompt type: %q", prompt) + } + + host = parts[2] + host = hostReplace.ReplaceAllString(host, "") + + // Validate the input URL to ensure it's in an expected format. + u, err := url.Parse(host) + if err != nil { + return "", "", xerrors.Errorf("parse host failed: %w", err) + } + + switch u.Scheme { + case "http", "https": + default: + return "", "", xerrors.Errorf("unsupported scheme: %q", u.Scheme) + } + + if u.Host == "" { + return "", "", xerrors.Errorf("host is empty") + } + + user = u.User.Username() + u.User = nil + host = u.String() + + return user, host, nil +} diff --git a/cli/gitaskpass/gitaskpass_test.go b/cli/gitaskpass/gitaskpass_test.go new file mode 100644 index 0000000000000..0b9883254bb99 --- /dev/null +++ b/cli/gitaskpass/gitaskpass_test.go @@ -0,0 +1,72 @@ +package gitaskpass_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/gitaskpass" +) + +func TestCheckCommand(t *testing.T) { + t.Parallel() + t.Run("Success", func(t *testing.T) { + t.Parallel() + valid := gitaskpass.CheckCommand([]string{"Username "}, []string{"GIT_PREFIX=/example"}) + require.True(t, valid) + }) + t.Run("Failure", func(t *testing.T) { + t.Parallel() + valid := gitaskpass.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 := gitaskpass.Parse(tc.in) + require.NoError(t, err) + require.Equal(t, tc.wantUser, user) + require.Equal(t, tc.wantHost, host) + }) + } +} diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index b946a1130e0c8..4ab2ef47d1d7f 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -165,6 +165,16 @@ CREATE TABLE files ( id uuid DEFAULT gen_random_uuid() NOT NULL ); +CREATE TABLE git_provider_links ( + user_id uuid NOT NULL, + url text 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 text NOT NULL +); + CREATE TABLE gitsshkeys ( user_id uuid NOT NULL, created_at timestamp with time zone NOT NULL, diff --git a/coderd/database/migrations/000062_gitprovider.down.sql b/coderd/database/migrations/000062_gitprovider.down.sql new file mode 100644 index 0000000000000..4b9b13d48ebf1 --- /dev/null +++ b/coderd/database/migrations/000062_gitprovider.down.sql @@ -0,0 +1 @@ +DROP TABLE git_provider_links; diff --git a/coderd/database/migrations/000062_gitprovider.up.sql b/coderd/database/migrations/000062_gitprovider.up.sql new file mode 100644 index 0000000000000..d423ce6211a42 --- /dev/null +++ b/coderd/database/migrations/000062_gitprovider.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS git_provider_links ( + user_id uuid NOT NULL, + url text 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 text NOT NULL +); diff --git a/coderd/database/models.go b/coderd/database/models.go index 53e074984ac11..17f2a573e4617 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -427,6 +427,16 @@ type File struct { ID uuid.UUID `db:"id" json:"id"` } +type GitProviderLink struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + Url string `db:"url" json:"url"` + 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 string `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"` From fb5195d9a541f0673cf38009ecb1f04220a0a688 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 18 Oct 2022 13:50:37 +0000 Subject: [PATCH 02/17] Move migration --- .../{000062_gitprovider.down.sql => 000063_gitprovider.down.sql} | 0 .../{000062_gitprovider.up.sql => 000063_gitprovider.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000062_gitprovider.down.sql => 000063_gitprovider.down.sql} (100%) rename coderd/database/migrations/{000062_gitprovider.up.sql => 000063_gitprovider.up.sql} (100%) diff --git a/coderd/database/migrations/000062_gitprovider.down.sql b/coderd/database/migrations/000063_gitprovider.down.sql similarity index 100% rename from coderd/database/migrations/000062_gitprovider.down.sql rename to coderd/database/migrations/000063_gitprovider.down.sql diff --git a/coderd/database/migrations/000062_gitprovider.up.sql b/coderd/database/migrations/000063_gitprovider.up.sql similarity index 100% rename from coderd/database/migrations/000062_gitprovider.up.sql rename to coderd/database/migrations/000063_gitprovider.up.sql From a10c4d5b2a686ac4df168aba071b207fabbdc20c Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 19 Oct 2022 14:17:09 +0000 Subject: [PATCH 03/17] Add endpoints for gitauth --- .vscode/settings.json | 2 + coderd/coderd.go | 14 + coderd/coderdtest/coderdtest.go | 2 + coderd/database/databasefake/databasefake.go | 53 ++++ coderd/database/dump.sql | 9 +- .../migrations/000063_gitauth.down.sql | 1 + ...tprovider.up.sql => 000063_gitauth.up.sql} | 7 +- .../migrations/000063_gitprovider.down.sql | 1 - coderd/database/models.go | 6 +- coderd/database/querier.go | 3 + coderd/database/queries.sql.go | 107 +++++++ coderd/database/queries/gitauth.sql | 29 ++ coderd/database/unique_constraint.go | 1 + coderd/gitauth.go | 274 ++++++++++++++++++ coderd/gitauth_test.go | 234 +++++++++++++++ coderd/userauth_test.go | 26 +- codersdk/{gitsshkey.go => gitauth.go} | 40 +++ site/src/api/typesGenerated.ts | 16 +- 18 files changed, 810 insertions(+), 15 deletions(-) create mode 100644 coderd/database/migrations/000063_gitauth.down.sql rename coderd/database/migrations/{000063_gitprovider.up.sql => 000063_gitauth.up.sql} (54%) delete mode 100644 coderd/database/migrations/000063_gitprovider.down.sql create mode 100644 coderd/database/queries/gitauth.sql create mode 100644 coderd/gitauth.go create mode 100644 coderd/gitauth_test.go rename codersdk/{gitsshkey.go => gitauth.go} (62%) diff --git a/.vscode/settings.json b/.vscode/settings.json index 9771a27a0de3e..6160f5ade34bd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "cSpell.words": [ "apps", + "ASKPASS", "awsidentity", "bodyclose", "buildinfo", @@ -29,6 +30,7 @@ "eventsourcemock", "fatih", "Formik", + "gitauth", "gitsshkey", "goarch", "gographviz", diff --git a/coderd/coderd.go b/coderd/coderd.go index cf8a20d3734cd..2a62a5a32b12e 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -3,6 +3,7 @@ package coderd import ( "crypto/tls" "crypto/x509" + "fmt" "io" "net/http" "net/url" @@ -82,6 +83,7 @@ type Options struct { Telemetry telemetry.Reporter TracerProvider trace.TracerProvider AutoImportTemplates []AutoImportTemplate + GitAuthConfigs []*GitAuthConfig // TLSCertificates is used to mesh DERP servers securely. TLSCertificates []tls.Certificate @@ -260,6 +262,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 @@ -465,6 +478,7 @@ func New(options *Options) *API { r.Get("/metadata", api.workspaceAgentMetadata) r.Post("/version", api.postWorkspaceAgentVersion) r.Post("/app-health", api.postWorkspaceAppHealth) + r.Get("/gitauth", api.workspaceAgentsGitAuth) r.Get("/gitsshkey", api.agentGitSSHKey) r.Get("/coordinate", api.workspaceAgentCoordinate) r.Get("/report-stats", api.workspaceAgentReportStats) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index d9ddc0bae2f20..3717d49bf62f9 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -84,6 +84,7 @@ type Options struct { AutobuildStats chan<- executor.Stats Auditor audit.Auditor TLSCertificates []tls.Certificate + GitAuthConfigs []*coderd.GitAuthConfig // IncludeProvisionerDaemon when true means to start an in-memory provisionerD IncludeProvisionerDaemon bool @@ -231,6 +232,7 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can Database: options.Database, Pubsub: options.Pubsub, Experimental: options.Experimental, + GitAuthConfigs: options.GitAuthConfigs, Auditor: options.Auditor, AWSCertificates: options.AWSCertificates, diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 757616774c6c7..556aa9f87f524 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -34,6 +34,7 @@ func New() database.Store { organizationMembers: make([]database.OrganizationMember, 0), organizations: make([]database.Organization, 0), users: make([]database.User, 0), + gitAuthLinks: make([]database.GitAuthLink, 0), groups: make([]database.Group, 0), groupMembers: make([]database.GroupMember, 0), auditLogs: make([]database.AuditLog, 0), @@ -90,6 +91,7 @@ type data struct { agentStats []database.AgentStat auditLogs []database.AuditLog files []database.File + gitAuthLinks []database.GitAuthLink gitSSHKey []database.GitSSHKey groups []database.Group groupMembers []database.GroupMember @@ -3286,3 +3288,54 @@ func (q *fakeQuerier) GetReplicasUpdatedAfter(_ context.Context, updatedAt time. } return replicas, nil } + +func (q *fakeQuerier) GetGitAuthLink(_ context.Context, arg database.GetGitAuthLinkParams) (database.GitAuthLink, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + for _, gitAuthLink := range q.gitAuthLinks { + if arg.UserID != gitAuthLink.UserID { + continue + } + if arg.ProviderID != gitAuthLink.ProviderID { + continue + } + return gitAuthLink, nil + } + return database.GitAuthLink{}, sql.ErrNoRows +} + +func (q *fakeQuerier) InsertGitAuthLink(_ context.Context, arg database.InsertGitAuthLinkParams) (database.GitAuthLink, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + // nolint:gosimple + gitAuthLink := database.GitAuthLink{ + ProviderID: arg.ProviderID, + UserID: arg.UserID, + CreatedAt: arg.CreatedAt, + UpdatedAt: arg.UpdatedAt, + OAuthAccessToken: arg.OAuthAccessToken, + OAuthRefreshToken: arg.OAuthRefreshToken, + OAuthExpiry: arg.OAuthExpiry, + } + q.gitAuthLinks = append(q.gitAuthLinks, gitAuthLink) + return gitAuthLink, nil +} + +func (q *fakeQuerier) UpdateGitAuthLink(_ context.Context, arg database.UpdateGitAuthLinkParams) error { + q.mutex.Lock() + defer q.mutex.Unlock() + for index, gitAuthLink := range q.gitAuthLinks { + if gitAuthLink.ProviderID != arg.ProviderID { + continue + } + if gitAuthLink.UserID != arg.UserID { + continue + } + gitAuthLink.UpdatedAt = arg.UpdatedAt + gitAuthLink.OAuthAccessToken = arg.OAuthAccessToken + gitAuthLink.OAuthRefreshToken = arg.OAuthRefreshToken + gitAuthLink.OAuthExpiry = arg.OAuthExpiry + q.gitAuthLinks[index] = gitAuthLink + } + return nil +} diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index aa2c14b76dfa5..1629e62c7bd5e 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -161,14 +161,14 @@ CREATE TABLE files ( id uuid DEFAULT gen_random_uuid() NOT NULL ); -CREATE TABLE git_provider_links ( +CREATE TABLE git_auth_links ( + provider_id text NOT NULL, user_id uuid NOT NULL, - url text 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 text NOT NULL + oauth_expiry timestamp with time zone NOT NULL ); CREATE TABLE gitsshkeys ( @@ -471,6 +471,9 @@ ALTER TABLE ONLY files ALTER TABLE ONLY files ADD CONSTRAINT files_pkey PRIMARY KEY (id); +ALTER TABLE ONLY git_auth_links + ADD CONSTRAINT git_auth_links_provider_id_user_id_key UNIQUE (provider_id, user_id); + ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_pkey PRIMARY KEY (user_id); diff --git a/coderd/database/migrations/000063_gitauth.down.sql b/coderd/database/migrations/000063_gitauth.down.sql new file mode 100644 index 0000000000000..758396230a861 --- /dev/null +++ b/coderd/database/migrations/000063_gitauth.down.sql @@ -0,0 +1 @@ +DROP TABLE git_auth_links; diff --git a/coderd/database/migrations/000063_gitprovider.up.sql b/coderd/database/migrations/000063_gitauth.up.sql similarity index 54% rename from coderd/database/migrations/000063_gitprovider.up.sql rename to coderd/database/migrations/000063_gitauth.up.sql index d423ce6211a42..7f4cb9f54c34e 100644 --- a/coderd/database/migrations/000063_gitprovider.up.sql +++ b/coderd/database/migrations/000063_gitauth.up.sql @@ -1,9 +1,10 @@ -CREATE TABLE IF NOT EXISTS git_provider_links ( +CREATE TABLE IF NOT EXISTS git_auth_links ( + provider_id text NOT NULL, user_id uuid NOT NULL, - url text 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 text NOT NULL + oauth_expiry timestamptz NOT NULL, + UNIQUE(provider_id, user_id) ); diff --git a/coderd/database/migrations/000063_gitprovider.down.sql b/coderd/database/migrations/000063_gitprovider.down.sql deleted file mode 100644 index 4b9b13d48ebf1..0000000000000 --- a/coderd/database/migrations/000063_gitprovider.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE git_provider_links; diff --git a/coderd/database/models.go b/coderd/database/models.go index 22395d2587a57..a82c8a52f801d 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -427,14 +427,14 @@ type File struct { ID uuid.UUID `db:"id" json:"id"` } -type GitProviderLink struct { +type GitAuthLink struct { + ProviderID string `db:"provider_id" json:"provider_id"` UserID uuid.UUID `db:"user_id" json:"user_id"` - Url string `db:"url" json:"url"` 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 string `db:"oauth_expiry" json:"oauth_expiry"` + OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"` } type GitSSHKey struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 393ab81fdd347..ff070d9ed53f1 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -43,6 +43,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) @@ -129,6 +130,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 @@ -154,6 +156,7 @@ type sqlcQuerier interface { ParameterValue(ctx context.Context, id uuid.UUID) (ParameterValue, error) ParameterValues(ctx context.Context, arg ParameterValuesParams) ([]ParameterValue, error) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error + UpdateGitAuthLink(ctx context.Context, arg UpdateGitAuthLinkParams) error UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error) UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index cb4b43591a41e..4d23c72fb56e2 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -741,6 +741,113 @@ func (q *sqlQuerier) InsertFile(ctx context.Context, arg InsertFileParams) (File return i, err } +const getGitAuthLink = `-- name: GetGitAuthLink :one +SELECT provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry FROM git_auth_links WHERE provider_id = $1 AND user_id = $2 +` + +type GetGitAuthLinkParams struct { + ProviderID string `db:"provider_id" json:"provider_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` +} + +func (q *sqlQuerier) GetGitAuthLink(ctx context.Context, arg GetGitAuthLinkParams) (GitAuthLink, error) { + row := q.db.QueryRowContext(ctx, getGitAuthLink, arg.ProviderID, arg.UserID) + var i GitAuthLink + err := row.Scan( + &i.ProviderID, + &i.UserID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OAuthAccessToken, + &i.OAuthRefreshToken, + &i.OAuthExpiry, + ) + return i, err +} + +const insertGitAuthLink = `-- name: InsertGitAuthLink :one +INSERT INTO git_auth_links ( + provider_id, + user_id, + created_at, + updated_at, + oauth_access_token, + oauth_refresh_token, + oauth_expiry +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7 +) RETURNING provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry +` + +type InsertGitAuthLinkParams struct { + ProviderID string `db:"provider_id" json:"provider_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"` + OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"` + OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"` +} + +func (q *sqlQuerier) InsertGitAuthLink(ctx context.Context, arg InsertGitAuthLinkParams) (GitAuthLink, error) { + row := q.db.QueryRowContext(ctx, insertGitAuthLink, + arg.ProviderID, + arg.UserID, + arg.CreatedAt, + arg.UpdatedAt, + arg.OAuthAccessToken, + arg.OAuthRefreshToken, + arg.OAuthExpiry, + ) + var i GitAuthLink + err := row.Scan( + &i.ProviderID, + &i.UserID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OAuthAccessToken, + &i.OAuthRefreshToken, + &i.OAuthExpiry, + ) + return i, err +} + +const updateGitAuthLink = `-- name: UpdateGitAuthLink :exec +UPDATE git_auth_links SET + updated_at = $3, + oauth_access_token = $4, + oauth_refresh_token = $5, + oauth_expiry = $6 +WHERE provider_id = $1 AND user_id = $2 +` + +type UpdateGitAuthLinkParams struct { + ProviderID string `db:"provider_id" json:"provider_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"` + OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"` + OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"` +} + +func (q *sqlQuerier) UpdateGitAuthLink(ctx context.Context, arg UpdateGitAuthLinkParams) error { + _, err := q.db.ExecContext(ctx, updateGitAuthLink, + arg.ProviderID, + arg.UserID, + arg.UpdatedAt, + arg.OAuthAccessToken, + arg.OAuthRefreshToken, + arg.OAuthExpiry, + ) + return err +} + const deleteGitSSHKey = `-- name: DeleteGitSSHKey :exec DELETE FROM gitsshkeys diff --git a/coderd/database/queries/gitauth.sql b/coderd/database/queries/gitauth.sql new file mode 100644 index 0000000000000..52a6fa76c48f4 --- /dev/null +++ b/coderd/database/queries/gitauth.sql @@ -0,0 +1,29 @@ +-- name: GetGitAuthLink :one +SELECT * FROM git_auth_links WHERE provider_id = $1 AND user_id = $2; + +-- name: InsertGitAuthLink :one +INSERT INTO git_auth_links ( + provider_id, + user_id, + created_at, + updated_at, + oauth_access_token, + oauth_refresh_token, + oauth_expiry +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7 +) RETURNING *; + +-- name: UpdateGitAuthLink :exec +UPDATE git_auth_links SET + updated_at = $3, + oauth_access_token = $4, + oauth_refresh_token = $5, + oauth_expiry = $6 +WHERE provider_id = $1 AND user_id = $2; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index b4263c09b4762..dbaddd46838c8 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -7,6 +7,7 @@ type UniqueConstraint string // UniqueConstraint enums. const ( UniqueFilesHashCreatedByKey UniqueConstraint = "files_hash_created_by_key" // ALTER TABLE ONLY files ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); + UniqueGitAuthLinksProviderIDUserIDKey UniqueConstraint = "git_auth_links_provider_id_user_id_key" // ALTER TABLE ONLY git_auth_links ADD CONSTRAINT git_auth_links_provider_id_user_id_key UNIQUE (provider_id, user_id); UniqueGroupMembersUserIDGroupIDKey UniqueConstraint = "group_members_user_id_group_id_key" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_group_id_key UNIQUE (user_id, group_id); UniqueGroupsNameOrganizationIDKey UniqueConstraint = "groups_name_organization_id_key" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_name_organization_id_key UNIQUE (name, organization_id); UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt); diff --git a/coderd/gitauth.go b/coderd/gitauth.go new file mode 100644 index 0000000000000..7e36dd9d9fe93 --- /dev/null +++ b/coderd/gitauth.go @@ -0,0 +1,274 @@ +package coderd + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + + "golang.org/x/oauth2" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/codersdk" +) + +// GitAuthConfig is the configuration for an authentication +// provider that is used for git operations. +type GitAuthConfig 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 +} + +// 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 *GitAuthConfig + 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.StatusBadRequest, 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.StatusBadRequest, 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.StatusBadRequest, 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() + select { + case <-r.Context().Done(): + return + case <-authChan: + } + + gitAuthLink, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ + ProviderID: gitAuthConfig.ID, + UserID: workspace.OwnerID, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to get git auth link.", + Detail: err.Error(), + }) + return + } + + 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. + url, err := api.AccessURL.Parse(fmt.Sprintf("/git-auth/%s", gitAuthConfig.ID)) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, 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.StatusBadRequest, codersdk.Response{ + Message: "Failed to get git auth link.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentGitAuthResponse{ + URL: url.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: url.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.StatusBadRequest, 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(_ codersdk.GitProvider, token string) codersdk.WorkspaceAgentGitAuthResponse { + resp := codersdk.WorkspaceAgentGitAuthResponse{ + Username: token, + } + return resp +} + +func (api *API) gitAuthCallback(gitAuthConfig *GitAuthConfig) 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) + } +} diff --git a/coderd/gitauth_test.go b/coderd/gitauth_test.go new file mode 100644 index 0000000000000..8edb86e30820f --- /dev/null +++ b/coderd/gitauth_test.go @@ -0,0 +1,234 @@ +package coderd_test + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/provisionersdk/proto" +) + +// 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: []*coderd.GitAuthConfig{}, + }) + 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: []*coderd.GitAuthConfig{{ + 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("/git-auth/%s", "github"))) + }) + t.Run("UnauthorizedCallback", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + GitAuthConfigs: []*coderd.GitAuthConfig{{ + 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: []*coderd.GitAuthConfig{{ + 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: []*coderd.GitAuthConfig{{ + OAuth2Config: &oauth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.GitProviderGitHub, + }}, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + + token, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", false) + require.NoError(t, err) + require.NotEmpty(t, token.URL) + + // Start waiting for the token callback... + tokenChan := make(chan codersdk.WorkspaceAgentGitAuthResponse, 1) + go func() { + token, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", true) + assert.NoError(t, err) + tokenChan <- token + }() + + time.Sleep(250 * time.Millisecond) + + resp := gitAuthCallback(t, "github", client) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + token = <-tokenChan + require.Equal(t, "token", token.Username) + + token, err = agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", false) + require.NoError(t, err) + }) +} + +func gitAuthCallback(t *testing.T, id string, client *codersdk.Client) *http.Response { + client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + state := "somestate" + oauthURL, err := client.URL.Parse(fmt.Sprintf("/gitauth/%s/callback?code=asd&state=%s", id, state)) + require.NoError(t, err) + req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil) + require.NoError(t, err) + req.AddCookie(&http.Cookie{ + Name: codersdk.OAuth2StateKey, + Value: state, + }) + req.AddCookie(&http.Cookie{ + Name: codersdk.SessionTokenKey, + Value: client.SessionToken, + }) + res, err := client.HTTPClient.Do(req) + require.NoError(t, err) + t.Cleanup(func() { + _ = res.Body.Close() + }) + return res +} diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 9643351032a88..7d63dde801a79 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -21,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" ) @@ -38,12 +39,31 @@ func (o *oauth2Config) Exchange(context.Context, string, ...oauth2.AuthCodeOptio return o.token, nil } return &oauth2.Token{ - AccessToken: "token", + AccessToken: "token", + RefreshToken: "refresh", + Expiry: database.Now().Add(time.Hour), }, nil } -func (*oauth2Config) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource { - return nil +func (o *oauth2Config) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource { + return &oauth2TokenSource{ + token: o.token, + } +} + +type oauth2TokenSource struct { + token *oauth2.Token +} + +func (o *oauth2TokenSource) Token() (*oauth2.Token, error) { + if o.token != nil { + return o.token, nil + } + return &oauth2.Token{ + AccessToken: "token", + RefreshToken: "refresh", + Expiry: database.Now().Add(time.Hour), + }, nil } func TestUserAuthMethods(t *testing.T) { diff --git a/codersdk/gitsshkey.go b/codersdk/gitauth.go similarity index 62% rename from codersdk/gitsshkey.go rename to codersdk/gitauth.go index e345a2733ab02..627fd93a8ac8d 100644 --- a/codersdk/gitsshkey.go +++ b/codersdk/gitauth.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "time" "github.com/google/uuid" @@ -70,3 +71,42 @@ func (c *Client) AgentGitSSHKey(ctx context.Context) (AgentGitSSHKey, error) { var agentgitsshkey AgentGitSSHKey return agentgitsshkey, json.NewDecoder(res.Body).Decode(&agentgitsshkey) } + +// 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. If the URL doesn't match +func (c *Client) WorkspaceAgentGitAuth(ctx context.Context, gitURL string, listen bool) (WorkspaceAgentGitAuthResponse, error) { + url := "/api/v2/workspaceagents/me/gitauth?url=" + url.QueryEscape(gitURL) + if listen { + url += "&listen" + } + res, err := c.Request(ctx, http.MethodGet, url, nil) + if err != nil { + return WorkspaceAgentGitAuthResponse{}, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return WorkspaceAgentGitAuthResponse{}, readBodyAsError(res) + } + + var authResp WorkspaceAgentGitAuthResponse + return authResp, json.NewDecoder(res.Body).Decode(&authResp) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0a709f8927a2a..5319cb711186a 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -18,7 +18,7 @@ export interface AddLicenseRequest { readonly license: string } -// From codersdk/gitsshkey.go +// From codersdk/gitproviders.go export interface AgentGitSSHKey { readonly public_key: string readonly private_key: string @@ -363,7 +363,7 @@ export interface GetAppHostResponse { readonly host: string } -// From codersdk/gitsshkey.go +// From codersdk/gitproviders.go export interface GitSSHKey { readonly user_id: string readonly created_at: string @@ -784,6 +784,18 @@ export interface WorkspaceAgent { readonly latency?: Record } +// From codersdk/gitproviders.go +export interface WorkspaceAgentGitAuthRequest { + readonly url: string +} + +// From codersdk/gitproviders.go +export interface WorkspaceAgentGitAuthResponse { + readonly username: string + readonly password: string + readonly url: string +} + // From codersdk/workspaceagents.go export interface WorkspaceAgentInstanceMetadata { readonly jail_orchestrator: string From 4c37c34abe46bf1b3553e32033f566bd5fcfba74 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 20 Oct 2022 17:08:40 +0000 Subject: [PATCH 04/17] Add configuration files and tests! --- .vscode/settings.json | 4 + cli/agent.go | 3 +- cli/config/file.go | 4 + cli/config/server.go | 56 ++++ cli/config/server.yaml | 25 ++ cli/config/server_test.go | 40 +++ cli/deployment/flags.go | 6 + cli/gitaskpass.go | 71 +++++ cli/gitaskpass_test.go | 1 + cli/root.go | 25 +- cli/server.go | 19 +- coderd/coderd.go | 3 +- coderd/coderdtest/coderdtest.go | 3 +- coderd/gitauth.go | 274 ------------------ .../gitauth/askpass.go | 6 +- .../gitauth/askpass_test.go | 10 +- coderd/gitauth/config.go | 114 ++++++++ coderd/gitauth/config_test.go | 78 +++++ coderd/gitauth/oauth.go | 83 ++++++ coderd/gitauth/oauth_test.go | 9 + coderd/gitauth_test.go | 234 --------------- coderd/httpapi/httpapi.go | 3 +- coderd/httpapi/username.go | 17 +- coderd/httpapi/username_test.go | 6 +- coderd/userauth.go | 3 +- coderd/workspaceagents.go | 263 +++++++++++++++++ coderd/workspaceagents_test.go | 217 ++++++++++++++ codersdk/flags.go | 1 + codersdk/{gitauth.go => gitsshkey.go} | 40 --- codersdk/workspaceagents.go | 40 +++ site/src/AppRouter.tsx | 11 + site/src/api/typesGenerated.ts | 11 +- site/src/pages/GitAuthPage/GitAuthPage.tsx | 60 ++++ 33 files changed, 1157 insertions(+), 583 deletions(-) create mode 100644 cli/config/server.go create mode 100644 cli/config/server.yaml create mode 100644 cli/config/server_test.go create mode 100644 cli/gitaskpass.go create mode 100644 cli/gitaskpass_test.go delete mode 100644 coderd/gitauth.go rename cli/gitaskpass/gitaskpass.go => coderd/gitauth/askpass.go (91%) rename cli/gitaskpass/gitaskpass_test.go => coderd/gitauth/askpass_test.go (83%) create mode 100644 coderd/gitauth/config.go create mode 100644 coderd/gitauth/config_test.go create mode 100644 coderd/gitauth/oauth.go create mode 100644 coderd/gitauth/oauth_test.go delete mode 100644 coderd/gitauth_test.go rename codersdk/{gitauth.go => gitsshkey.go} (62%) create mode 100644 site/src/pages/GitAuthPage/GitAuthPage.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 6160f5ade34bd..e0ce7f5382b47 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,13 +20,16 @@ "derphttp", "derpmap", "devel", + "devtunnel", "dflags", "drpc", "drpcconn", "drpcmux", "drpcserver", "Dsts", + "embeddedpostgres", "enablements", + "errgroup", "eventsourcemock", "fatih", "Formik", @@ -80,6 +83,7 @@ "parameterscopeid", "pqtype", "prometheusmetrics", + "promhttp", "promptui", "protobuf", "provisionerd", diff --git a/cli/agent.go b/cli/agent.go index de1bd87352aea..10b136981e6ce 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -194,8 +194,9 @@ func workspaceAgent() *cobra.Command { Logger: logger, 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, }, CoordinatorDialer: client.ListenWorkspaceAgentTailnet, StatsReporter: client.AgentReportStats, diff --git a/cli/config/file.go b/cli/config/file.go index 388ce0881f304..1de4490173492 100644 --- a/cli/config/file.go +++ b/cli/config/file.go @@ -42,6 +42,10 @@ func (r Root) PostgresPort() File { return File(filepath.Join(r.PostgresPath(), "port")) } +func (r Root) ServerConfig() File { + return File(filepath.Join(string(r), "server.yaml")) +} + // File provides convenience methods for interacting with *os.File. type File string diff --git a/cli/config/server.go b/cli/config/server.go new file mode 100644 index 0000000000000..559bf81594ee4 --- /dev/null +++ b/cli/config/server.go @@ -0,0 +1,56 @@ +package config + +import ( + "errors" + "net/url" + "os" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/gitauth" + + _ "embed" +) + +//go:embed server.yaml +var defaultServer string + +// Server represents a parsed server configuration. +type Server struct { + GitAuth []*gitauth.Config +} + +// ParseServer creates or consumes a server config by path. +// If one does not exist, it will create one. If it fails to create, +// a warning will appear but the server will not fail to start. +// This is to prevent blocking execution on readonly file-systems +// that didn't provide a default config. +func ParseServer(cmd *cobra.Command, accessURL *url.URL, path string) (*Server, error) { + _, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + err = os.WriteFile(path, []byte(defaultServer), 0600) + if err != nil { + cmd.Printf("%s Unable to write the default config file: %s", cliui.Styles.Warn.Render("Warning:"), err) + } + } + data, err := os.ReadFile(path) + if err != nil { + data = []byte(defaultServer) + } + var server struct { + GitAuth []*gitauth.YAML `yaml:"gitauth"` + } + err = yaml.Unmarshal(data, &server) + if err != nil { + return nil, err + } + configs, err := gitauth.ConvertYAML(server.GitAuth, accessURL) + if err != nil { + return nil, err + } + return &Server{ + GitAuth: configs, + }, nil +} diff --git a/cli/config/server.yaml b/cli/config/server.yaml new file mode 100644 index 0000000000000..8dcee73a0b12f --- /dev/null +++ b/cli/config/server.yaml @@ -0,0 +1,25 @@ +# Coder Server Configuration + +# Automatically authenticate HTTP(s) Git requests. +gitauth: +# Supported: azure-devops, bitbucket, github, gitlab +# - type: github +# client_id: xxxxxx +# client_secret: xxxxxx + +# Multiple providers are an Enterprise feature. +# Contact sales@coder.com for a license. +# +# If multiple providers are used, a unique "id" +# must be provided for each one. +# - id: example +# type: azure-devops +# client_id: xxxxxxx +# client_secret: xxxxxxx +# A custom regex can be used to match a specific +# repository or organization to limit auth scope. +# regex: github.com/coder +# Custom authentication and token URLs should be +# used for self-managed Git provider deployments. +# auth_url: https://example.com/oauth/authorize +# token_url: https://example.com/oauth/token diff --git a/cli/config/server_test.go b/cli/config/server_test.go new file mode 100644 index 0000000000000..3fda519fbdaf4 --- /dev/null +++ b/cli/config/server_test.go @@ -0,0 +1,40 @@ +package config_test + +import ( + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/config" +) + +func TestServer(t *testing.T) { + t.Parallel() + t.Run("WritesDefault", func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "server.yaml") + _, err := config.ParseServer(&cobra.Command{}, &url.URL{}, path) + require.NoError(t, err) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Greater(t, len(data), 0) + }) + t.Run("Filled", func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "server.yaml") + err := os.WriteFile(path, []byte(` +gitauth: +- type: github + client_id: xxx + client_secret: xxx +`), 0600) + require.NoError(t, err) + cfg, err := config.ParseServer(&cobra.Command{}, &url.URL{}, path) + require.NoError(t, err) + require.Len(t, cfg.GitAuth, 1) + }) +} diff --git a/cli/deployment/flags.go b/cli/deployment/flags.go index 792051f805f70..5dbaade81dda4 100644 --- a/cli/deployment/flags.go +++ b/cli/deployment/flags.go @@ -50,6 +50,12 @@ func Flags() *codersdk.DeploymentFlags { Hidden: true, Default: time.Minute, }, + ConfigPath: &codersdk.StringFlag{ + Name: "Configuration Path", + Flag: "config", + EnvVar: "CODER_SERVER_CONFIG", + Description: "Path to the Coder configuration file.", + }, DerpServerEnable: &codersdk.BoolFlag{ Name: "DERP Server Enabled", Flag: "derp-server-enable", diff --git a/cli/gitaskpass.go b/cli/gitaskpass.go new file mode 100644 index 0000000000000..4e3ad34040988 --- /dev/null +++ b/cli/gitaskpass.go @@ -0,0 +1,71 @@ +package cli + +import ( + "fmt" + "os/signal" + "time" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/gitauth" + "github.com/coder/retry" +) + +func gitAskpass() *cobra.Command { + return &cobra.Command{ + Use: "gitaskpass", + Hidden: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) (err error) { + ctx := cmd.Context() + + ctx, stop := signal.NotifyContext(ctx, interruptSignals...) + defer stop() + + defer func() { + if ctx.Err() != nil { + err = ctx.Err() + } + }() + + 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 { + return xerrors.Errorf("get git token: %w", err) + } + if token.URL != "" { + cmd.Printf("Visit the following URL to authenticate with Git:\n%s\n", token.URL) + for r := retry.New(time.Second, 10*time.Second); r.Wait(ctx); { + token, err = client.WorkspaceAgentGitAuth(ctx, host, true) + if err != nil { + continue + } + cmd.Printf("\nYou've been authenticated with Git!\n") + break + } + } + + if token.Password != "" { + if user == "" { + fmt.Fprintln(cmd.OutOrStdout(), token.Username) + } else { + fmt.Fprintln(cmd.OutOrStdout(), token.Password) + } + } else { + fmt.Fprintln(cmd.OutOrStdout(), token.Username) + } + + return nil + }, + } +} diff --git a/cli/gitaskpass_test.go b/cli/gitaskpass_test.go new file mode 100644 index 0000000000000..763604961c19c --- /dev/null +++ b/cli/gitaskpass_test.go @@ -0,0 +1 @@ +package cli_test diff --git a/cli/root.go b/cli/root.go index ea803cdfa5d81..03c617d294103 100644 --- a/cli/root.go +++ b/cli/root.go @@ -25,6 +25,7 @@ import ( "github.com/coder/coder/cli/config" "github.com/coder/coder/cli/deployment" "github.com/coder/coder/coderd" + "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/codersdk" ) @@ -109,12 +110,31 @@ 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 + cmd := &cobra.Command{ Use: "coder", SilenceErrors: true, SilenceUsage: true, Long: `Coder — A tool for provisioning self-hosted development environments with Terraform. -`, +`, 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 +154,9 @@ func Root(subcommands []*cobra.Command) *cobra.Command { if cmd.Name() == "login" || cmd.Name() == "server" || cmd.Name() == "agent" || cmd.Name() == "gitssh" { return } + if isGitAskpass { + return + } client, err := CreateClient(cmd) // If we are unable to create a client, presumably the subcommand will fail as well diff --git a/cli/server.go b/cli/server.go index 25b435ed6c673..68f1c12177de3 100644 --- a/cli/server.go +++ b/cli/server.go @@ -90,7 +90,7 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code // 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. // @@ -143,13 +143,13 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code } } - config := createConfig(cmd) + cfg := createConfig(cmd) builtinPostgres := false // Only use built-in if PostgreSQL URL isn't specified! if !dflags.InMemoryDatabase.Value && dflags.PostgresURL.Value == "" { var closeFunc func() error - cmd.Printf("Using built-in PostgreSQL (%s)\n", config.PostgresPath()) - dflags.PostgresURL.Value, closeFunc, err = startBuiltinPostgres(ctx, config, logger) + cmd.Printf("Using built-in PostgreSQL (%s)\n", cfg.PostgresPath()) + dflags.PostgresURL.Value, closeFunc, err = startBuiltinPostgres(ctx, cfg, logger) if err != nil { return err } @@ -311,6 +311,14 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code } } + if dflags.ConfigPath.Value == "" { + dflags.ConfigPath.Value = string(cfg.ServerConfig()) + } + serverConfig, err := config.ParseServer(cmd, accessURLParsed, dflags.ConfigPath.Value) + if err != nil { + return xerrors.Errorf("parse server config: %w", err) + } + options := &coderd.Options{ AccessURL: accessURLParsed, AppHostname: appHostname, @@ -321,6 +329,7 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code Pubsub: database.NewPubsubInMemory(), CacheDir: dflags.CacheDir.Value, GoogleTokenValidator: googleTokenValidator, + GitAuthConfigs: serverConfig.GitAuth, SecureAuthCookie: dflags.SecureAuthCookie.Value, SSHKeygenAlgorithm: sshKeygenAlgorithm, TracerProvider: tracerProvider, @@ -602,7 +611,7 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code // This is helpful for tests, but can be silently ignored. // Coder may be ran as users that don't have permission to write in the homedir, // such as via the systemd service. - _ = config.URL().Write(client.URL.String()) + _ = cfg.URL().Write(client.URL.String()) // Currently there is no way to ask the server to shut // itself down, so any exit signal will result in a non-zero diff --git a/coderd/coderd.go b/coderd/coderd.go index 2a62a5a32b12e..0e7a93485ac60 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -31,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" @@ -83,7 +84,7 @@ type Options struct { Telemetry telemetry.Reporter TracerProvider trace.TracerProvider AutoImportTemplates []AutoImportTemplate - GitAuthConfigs []*GitAuthConfig + GitAuthConfigs []*gitauth.Config // TLSCertificates is used to mesh DERP servers securely. TLSCertificates []tls.Certificate diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 3717d49bf62f9..44b736686f579 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -53,6 +53,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/rbac" @@ -84,7 +85,7 @@ type Options struct { AutobuildStats chan<- executor.Stats Auditor audit.Auditor TLSCertificates []tls.Certificate - GitAuthConfigs []*coderd.GitAuthConfig + GitAuthConfigs []*gitauth.Config // IncludeProvisionerDaemon when true means to start an in-memory provisionerD IncludeProvisionerDaemon bool diff --git a/coderd/gitauth.go b/coderd/gitauth.go deleted file mode 100644 index 7e36dd9d9fe93..0000000000000 --- a/coderd/gitauth.go +++ /dev/null @@ -1,274 +0,0 @@ -package coderd - -import ( - "context" - "database/sql" - "errors" - "fmt" - "net/http" - "regexp" - "strings" - - "golang.org/x/oauth2" - - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" -) - -// GitAuthConfig is the configuration for an authentication -// provider that is used for git operations. -type GitAuthConfig 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 -} - -// 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 *GitAuthConfig - 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.StatusBadRequest, 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.StatusBadRequest, 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.StatusBadRequest, 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() - select { - case <-r.Context().Done(): - return - case <-authChan: - } - - gitAuthLink, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ - ProviderID: gitAuthConfig.ID, - UserID: workspace.OwnerID, - }) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Failed to get git auth link.", - Detail: err.Error(), - }) - return - } - - 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. - url, err := api.AccessURL.Parse(fmt.Sprintf("/git-auth/%s", gitAuthConfig.ID)) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, 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.StatusBadRequest, codersdk.Response{ - Message: "Failed to get git auth link.", - Detail: err.Error(), - }) - return - } - - httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentGitAuthResponse{ - URL: url.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: url.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.StatusBadRequest, 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(_ codersdk.GitProvider, token string) codersdk.WorkspaceAgentGitAuthResponse { - resp := codersdk.WorkspaceAgentGitAuthResponse{ - Username: token, - } - return resp -} - -func (api *API) gitAuthCallback(gitAuthConfig *GitAuthConfig) 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) - } -} diff --git a/cli/gitaskpass/gitaskpass.go b/coderd/gitauth/askpass.go similarity index 91% rename from cli/gitaskpass/gitaskpass.go rename to coderd/gitauth/askpass.go index cb3e758f6aa3d..6420a4898c2c8 100644 --- a/cli/gitaskpass/gitaskpass.go +++ b/coderd/gitauth/askpass.go @@ -1,4 +1,4 @@ -package gitaskpass +package gitauth import ( "net/url" @@ -25,13 +25,13 @@ func CheckCommand(args, env []string) bool { return false } -// Parse returns the user and host from a git askpass prompt. For +// 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 Parse(prompt string) (user string, host string, err error) { +func ParseAskpass(prompt string) (user string, host string, err error) { parts := strings.Split(prompt, " ") if len(parts) < 3 { return "", "", xerrors.Errorf("askpass prompt must contain 3 words; got %d: %q", len(parts), prompt) diff --git a/cli/gitaskpass/gitaskpass_test.go b/coderd/gitauth/askpass_test.go similarity index 83% rename from cli/gitaskpass/gitaskpass_test.go rename to coderd/gitauth/askpass_test.go index 0b9883254bb99..ce7cc75989603 100644 --- a/cli/gitaskpass/gitaskpass_test.go +++ b/coderd/gitauth/askpass_test.go @@ -1,23 +1,23 @@ -package gitaskpass_test +package gitauth_test import ( "testing" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/gitaskpass" + "github.com/coder/coder/coderd/gitauth" ) func TestCheckCommand(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() - valid := gitaskpass.CheckCommand([]string{"Username "}, []string{"GIT_PREFIX=/example"}) + valid := gitauth.CheckCommand([]string{"Username "}, []string{"GIT_PREFIX=/example"}) require.True(t, valid) }) t.Run("Failure", func(t *testing.T) { t.Parallel() - valid := gitaskpass.CheckCommand([]string{}, []string{}) + valid := gitauth.CheckCommand([]string{}, []string{}) require.False(t, valid) }) } @@ -63,7 +63,7 @@ func TestParse(t *testing.T) { tc := tc t.Run(tc.in, func(t *testing.T) { t.Parallel() - user, host, err := gitaskpass.Parse(tc.in) + user, host, err := gitauth.ParseAskpass(tc.in) require.NoError(t, err) require.Equal(t, tc.wantUser, user) require.Equal(t, tc.wantHost, host) diff --git a/coderd/gitauth/config.go b/coderd/gitauth/config.go new file mode 100644 index 0000000000000..0c507e0f478db --- /dev/null +++ b/coderd/gitauth/config.go @@ -0,0 +1,114 @@ +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 +} + +// YAML represents a serializable config. +type YAML struct { + ID string `yaml:"id"` + Type string `yaml:"type"` + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + AuthURL string `yaml:"auth_url"` + TokenURL string `yaml:"token_url"` + Regex string `yaml:"regex"` +} + +// ConvertYAML converts the YAML configuration entry to the +// parsed and ready-to-consume provider type. +func ConvertYAML(entries []*YAML, 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, err := httpapi.UsernameValid(entry.ID); !valid { + return nil, xerrors.Errorf("git auth provider %q doesn't have a valid id: %w", entry.ID, err) + } + + _, exists := ids[entry.ID] + if exists { + if entry.ID == string(typ) { + return nil, xerrors.Errorf("multiple %s git auth providers provided. you must specify a unique id for each", typ) + } + return nil, xerrors.Errorf("multiple git providers exist with the id %q. specify a unique id for each", entry.ID) + } + ids[entry.ID] = struct{}{} + + if entry.ClientID == "" { + return nil, xerrors.Errorf("%q git auth provider: client_id must be provided", entry.ID) + } + if entry.ClientSecret == "" { + return nil, xerrors.Errorf("%q git auth provider: client_secret must be provided", entry.ID) + } + authRedirect, err := accessURL.Parse(fmt.Sprintf("/gitauth/%s/callback", entry.ID)) + if err != nil { + return nil, xerrors.Errorf("parse gitauth callback url: %w", err) + } + regex := regex[typ] + if entry.Regex != "" { + regex, err = regexp.Compile(entry.Regex) + if err != nil { + return nil, xerrors.Errorf("compile regex for git auth provider %q: %w", entry.ID, entry.Regex) + } + } + + oauth2Config := &oauth2.Config{ + ClientID: entry.ClientID, + ClientSecret: entry.ClientSecret, + Endpoint: endpoint[typ], + RedirectURL: authRedirect.String(), + Scopes: scope[typ], + } + + var oauthConfig httpmw.OAuth2Config = oauth2Config + // Azure DevOps uses JWT token authentication! + if typ == codersdk.GitProviderAzureDevops { + oauthConfig = newJWTOAuthConfig(oauth2Config) + } + + configs = append(configs, &Config{ + OAuth2Config: oauthConfig, + ID: entry.ID, + Regex: regex, + Type: typ, + }) + } + return configs, nil +} diff --git a/coderd/gitauth/config_test.go b/coderd/gitauth/config_test.go new file mode 100644 index 0000000000000..c9494a4882329 --- /dev/null +++ b/coderd/gitauth/config_test.go @@ -0,0 +1,78 @@ +package gitauth_test + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/gitauth" + "github.com/coder/coder/codersdk" +) + +func TestConvertYAML(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + Name string + Input []*gitauth.YAML + Output []*gitauth.Config + Error string + }{{ + Name: "InvalidType", + Input: []*gitauth.YAML{{ + Type: "moo", + }}, + Error: "unknown git provider type", + }, { + Name: "InvalidID", + Input: []*gitauth.YAML{{ + Type: codersdk.GitProviderGitHub, + ID: "$hi$", + }}, + Error: "doesn't have a valid id", + }, { + Name: "NoClientID", + Input: []*gitauth.YAML{{ + Type: codersdk.GitProviderGitHub, + }}, + Error: "client_id must be provided", + }, { + Name: "NoClientSecret", + Input: []*gitauth.YAML{{ + Type: codersdk.GitProviderGitHub, + ClientID: "example", + }}, + Error: "client_secret must be provided", + }, { + Name: "DuplicateType", + Input: []*gitauth.YAML{{ + Type: codersdk.GitProviderGitHub, + ClientID: "example", + ClientSecret: "example", + }, { + Type: codersdk.GitProviderGitHub, + }}, + Error: "multiple github git auth providers provided", + }, { + Name: "InvalidRegex", + Input: []*gitauth.YAML{{ + 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.ConvertYAML(tc.Input, &url.URL{}) + if tc.Error != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.Error) + return + } + require.Equal(t, tc.Output, output) + }) + } +} diff --git a/coderd/gitauth/oauth.go b/coderd/gitauth/oauth.go new file mode 100644 index 0000000000000..f1c63515f32a8 --- /dev/null +++ b/coderd/gitauth/oauth.go @@ -0,0 +1,83 @@ +package gitauth + +import ( + "context" + "net/url" + "regexp" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/github" + + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/codersdk" +) + +// endpoint contains default SaaS URLs for each Git provider. +var endpoint = map[codersdk.GitProvider]oauth2.Endpoint{ + codersdk.GitProviderAzureDevops: { + AuthURL: "https://app.vssps.visualstudio.com/oauth2/authorize", + TokenURL: "https://app.vssps.visualstudio.com/oauth2/token", + }, + codersdk.GitProviderBitBucket: { + AuthURL: "https://bitbucket.org/site/oauth2/authorize", + TokenURL: "https://bitbucket.org/site/oauth2/access_token", + }, + codersdk.GitProviderGitLab: { + AuthURL: "https://gitlab.com/oauth/authorize", + TokenURL: "https://gitlab.com/oauth/token", + }, + codersdk.GitProviderGitHub: github.Endpoint, +} + +// scope contains defaults for each Git provider. +var scope = map[codersdk.GitProvider][]string{ + codersdk.GitProviderAzureDevops: {"vso.code_write"}, + codersdk.GitProviderBitBucket: {"repository:write"}, + codersdk.GitProviderGitLab: {"write_repository"}, + codersdk.GitProviderGitHub: {"repo"}, +} + +// regex provides defaults for each Git provider to +// match their SaaS host URL. This is configurable by each provider. +var regex = map[codersdk.GitProvider]*regexp.Regexp{ + codersdk.GitProviderAzureDevops: regexp.MustCompile(`dev\.azure\.com`), + codersdk.GitProviderBitBucket: regexp.MustCompile(`bitbucket\.org`), + codersdk.GitProviderGitLab: regexp.MustCompile(`gitlab\.com`), + codersdk.GitProviderGitHub: regexp.MustCompile(`github\.com`), +} + +// newJWTOAuthConfig creates a new OAuth2 config that uses a custom +// assertion method that works with Azure Devops. See: +// https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/oauth?view=azure-devops +func newJWTOAuthConfig(config *oauth2.Config) httpmw.OAuth2Config { + return &jwtConfig{config} +} + +type jwtConfig struct { + *oauth2.Config +} + +func (c *jwtConfig) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { + return c.Config.AuthCodeURL(state, append(opts, oauth2.SetAuthURLParam("response_type", "Assertion"))...) +} + +func (c *jwtConfig) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + v := url.Values{ + "client_assertion_type": {}, + "client_assertion": {c.ClientSecret}, + "assertion": {code}, + "grant_type": {}, + } + if c.RedirectURL != "" { + v.Set("redirect_uri", c.RedirectURL) + } + return c.Config.Exchange(ctx, code, + append(opts, + oauth2.SetAuthURLParam("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"), + oauth2.SetAuthURLParam("client_assertion", c.ClientSecret), + oauth2.SetAuthURLParam("assertion", code), + oauth2.SetAuthURLParam("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + oauth2.SetAuthURLParam("code", ""), + )..., + ) +} diff --git a/coderd/gitauth/oauth_test.go b/coderd/gitauth/oauth_test.go new file mode 100644 index 0000000000000..b85baeb935ed5 --- /dev/null +++ b/coderd/gitauth/oauth_test.go @@ -0,0 +1,9 @@ +package gitauth_test + +import ( + "testing" +) + +func TestOAuthJWTConfig(t *testing.T) { + t.Parallel() +} diff --git a/coderd/gitauth_test.go b/coderd/gitauth_test.go deleted file mode 100644 index 8edb86e30820f..0000000000000 --- a/coderd/gitauth_test.go +++ /dev/null @@ -1,234 +0,0 @@ -package coderd_test - -import ( - "context" - "fmt" - "net/http" - "regexp" - "strings" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/coder/coder/coderd" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisionersdk/proto" -) - -// 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: []*coderd.GitAuthConfig{}, - }) - 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: []*coderd.GitAuthConfig{{ - 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("/git-auth/%s", "github"))) - }) - t.Run("UnauthorizedCallback", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - GitAuthConfigs: []*coderd.GitAuthConfig{{ - 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: []*coderd.GitAuthConfig{{ - 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: []*coderd.GitAuthConfig{{ - OAuth2Config: &oauth2Config{}, - ID: "github", - Regex: regexp.MustCompile(`github\.com`), - Type: codersdk.GitProviderGitHub, - }}, - }) - user := coderdtest.CreateFirstUser(t, client) - authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionDryRun: echo.ProvisionComplete, - Provision: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "example", - Type: "aws_instance", - Agents: []*proto.Agent{{ - Id: uuid.NewString(), - Auth: &proto.Agent_Token{ - Token: authToken, - }, - }}, - }}, - }, - }, - }}, - }) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - - agentClient := codersdk.New(client.URL) - agentClient.SessionToken = authToken - - token, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", false) - require.NoError(t, err) - require.NotEmpty(t, token.URL) - - // Start waiting for the token callback... - tokenChan := make(chan codersdk.WorkspaceAgentGitAuthResponse, 1) - go func() { - token, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", true) - assert.NoError(t, err) - tokenChan <- token - }() - - time.Sleep(250 * time.Millisecond) - - resp := gitAuthCallback(t, "github", client) - require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) - token = <-tokenChan - require.Equal(t, "token", token.Username) - - token, err = agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", false) - require.NoError(t, err) - }) -} - -func gitAuthCallback(t *testing.T, id string, client *codersdk.Client) *http.Response { - client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - } - state := "somestate" - oauthURL, err := client.URL.Parse(fmt.Sprintf("/gitauth/%s/callback?code=asd&state=%s", id, state)) - require.NoError(t, err) - req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil) - require.NoError(t, err) - req.AddCookie(&http.Cookie{ - Name: codersdk.OAuth2StateKey, - Value: state, - }) - req.AddCookie(&http.Cookie{ - Name: codersdk.SessionTokenKey, - Value: client.SessionToken, - }) - res, err := client.HTTPClient.Do(req) - require.NoError(t, err) - t.Cleanup(func() { - _ = res.Body.Close() - }) - return res -} diff --git a/coderd/httpapi/httpapi.go b/coderd/httpapi/httpapi.go index 3d3def7ee493e..ea144569f5e19 100644 --- a/coderd/httpapi/httpapi.go +++ b/coderd/httpapi/httpapi.go @@ -40,7 +40,8 @@ func init() { if !ok { return false } - return UsernameValid(str) + valid, _ := UsernameValid(str) + return valid } for _, tag := range []string{"username", "template_name", "workspace_name"} { err := validate.RegisterValidation(tag, nameValidator) diff --git a/coderd/httpapi/username.go b/coderd/httpapi/username.go index f41e0d96d3205..b20b55ce7e3e4 100644 --- a/coderd/httpapi/username.go +++ b/coderd/httpapi/username.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/moby/moby/pkg/namesgenerator" + "golang.org/x/xerrors" ) var ( @@ -13,14 +14,18 @@ var ( ) // UsernameValid returns whether the input string is a valid username. -func UsernameValid(str string) bool { +func UsernameValid(str string) (bool, error) { if len(str) > 32 { - return false + return false, xerrors.New("must be <= 32 characters") } if len(str) < 1 { - return false + return false, xerrors.New("must be >= 1 character") } - return UsernameValidRegex.MatchString(str) + matched := UsernameValidRegex.MatchString(str) + if !matched { + return false, xerrors.New("must be alphanumeric with hyphens") + } + return true, 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 { 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 { return str } return strings.ReplaceAll(namesgenerator.GetRandomName(1), "_", "-") diff --git a/coderd/httpapi/username_test.go b/coderd/httpapi/username_test.go index fa6c0e1434233..57fab3f0b70fe 100644 --- a/coderd/httpapi/username_test.go +++ b/coderd/httpapi/username_test.go @@ -59,7 +59,8 @@ func TestValid(t *testing.T) { testCase := testCase t.Run(testCase.Username, func(t *testing.T) { t.Parallel() - require.Equal(t, testCase.Valid, httpapi.UsernameValid(testCase.Username)) + valid, _ := httpapi.UsernameValid(testCase.Username) + require.Equal(t, testCase.Valid, valid) }) } } @@ -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) if testCase.Match == "" { require.NotEqual(t, testCase.From, converted) } else { diff --git a/coderd/userauth.go b/coderd/userauth.go index 7a18b9790df04..7556bbd2a6c95 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -260,7 +260,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 { // If no username is provided, we can default to use the email address. // This will be converted in the from function below, so it's safe // to keep the domain. diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 250b4c73ae3f9..8346d5b5cb470 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "io" "net" @@ -18,6 +19,7 @@ import ( "github.com/google/uuid" "go.opentelemetry.io/otel/trace" "golang.org/x/mod/semver" + "golang.org/x/oauth2" "golang.org/x/xerrors" "nhooyr.io/websocket" "nhooyr.io/websocket/wsjson" @@ -25,6 +27,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" @@ -930,6 +933,266 @@ 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.StatusBadRequest, 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.StatusBadRequest, 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.StatusBadRequest, 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() + select { + case <-r.Context().Done(): + return + case <-authChan: + } + + gitAuthLink, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ + ProviderID: gitAuthConfig.ID, + UserID: workspace.OwnerID, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to get git auth link.", + Detail: err.Error(), + }) + return + } + + 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. + url, err := api.AccessURL.Parse(fmt.Sprintf("/gitauth/%s", gitAuthConfig.ID)) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, 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.StatusBadRequest, codersdk.Response{ + Message: "Failed to get git auth link.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentGitAuthResponse{ + URL: url.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: url.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.StatusBadRequest, codersdk.Response{ + Message: "Failed to update git auth link.", + Detail: err.Error(), + }) + return + } + } + httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(gitAuthConfig.Type, token.AccessToken)) +} + +// Provider types have different username/password formats. +func formatGitAuthAccessToken(typ codersdk.GitProvider, token string) codersdk.WorkspaceAgentGitAuthResponse { + var resp codersdk.WorkspaceAgentGitAuthResponse + switch typ { + case codersdk.GitProviderGitLab: + // https://stackoverflow.com/questions/25409700/using-gitlab-token-to-clone-without-authentication + resp = codersdk.WorkspaceAgentGitAuthResponse{ + Username: "oauth2", + Password: token, + } + case codersdk.GitProviderBitBucket: + // https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/#Cloning-a-repository-with-an-access-token + resp = codersdk.WorkspaceAgentGitAuthResponse{ + Username: "x-token-auth", + Password: token, + } + default: + resp = codersdk.WorkspaceAgentGitAuthResponse{ + Username: token, + } + } + return resp +} + +func (api *API) gitAuthCallback(gitAuthConfig *gitauth.Config) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + state = httpmw.OAuth2(r) + apiKey = httpmw.APIKey(r) + ) + + _, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ + ProviderID: gitAuthConfig.ID, + UserID: apiKey.UserID, + }) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to get git auth link.", + Detail: err.Error(), + }) + return + } + + _, err = api.Database.InsertGitAuthLink(ctx, database.InsertGitAuthLinkParams{ + ProviderID: gitAuthConfig.ID, + UserID: apiKey.UserID, + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + OAuthAccessToken: state.Token.AccessToken, + OAuthRefreshToken: state.Token.RefreshToken, + OAuthExpiry: state.Token.Expiry, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to insert git auth link.", + Detail: err.Error(), + }) + return + } + } else { + err = api.Database.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{ + ProviderID: gitAuthConfig.ID, + UserID: apiKey.UserID, + UpdatedAt: database.Now(), + OAuthAccessToken: state.Token.AccessToken, + OAuthRefreshToken: state.Token.RefreshToken, + OAuthExpiry: state.Token.Expiry, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to update git auth link.", + Detail: err.Error(), + }) + return + } + } + + err = api.Pubsub.Publish("gitauth", []byte(fmt.Sprintf("%s|%s", gitAuthConfig.ID, apiKey.UserID))) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to publish auth update.", + Detail: err.Error(), + }) + return + } + + // This is a nicely rendered screen on the frontend + http.Redirect(rw, r, "/gitauth", http.StatusTemporaryRedirect) + } +} + // wsNetConn wraps net.Conn created by websocket.NetConn(). Cancel func // is called if a read or write error is encountered. type wsNetConn struct { diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index e8dd772095736..7119dc1f6348c 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "net" + "net/http" + "regexp" "runtime" "strconv" "strings" @@ -13,12 +15,14 @@ import ( "time" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/agent" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" @@ -722,3 +726,216 @@ func TestWorkspaceAgentAppHealth(t *testing.T) { require.NoError(t, err) require.EqualValues(t, codersdk.WorkspaceAppHealthUnhealthy, apiApps[1].Health) } + +// nolint:bodyclose +func TestWorkspaceAgentsGitAuth(t *testing.T) { + t.Parallel() + t.Run("NoMatchingConfig", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + GitAuthConfigs: []*gitauth.Config{}, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + _, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com", false) + var apiError *codersdk.Error + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusNotFound, apiError.StatusCode()) + }) + t.Run("ReturnsURL", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + GitAuthConfigs: []*gitauth.Config{{ + OAuth2Config: &oauth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.GitProviderGitHub, + }}, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + token, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", false) + require.NoError(t, err) + require.True(t, strings.HasSuffix(token.URL, fmt.Sprintf("/gitauth/%s", "github"))) + }) + t.Run("UnauthorizedCallback", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + GitAuthConfigs: []*gitauth.Config{{ + OAuth2Config: &oauth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.GitProviderGitHub, + }}, + }) + resp := gitAuthCallback(t, "github", client) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + t.Run("AuthorizedCallback", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + GitAuthConfigs: []*gitauth.Config{{ + OAuth2Config: &oauth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.GitProviderGitHub, + }}, + }) + _ = coderdtest.CreateFirstUser(t, client) + resp := gitAuthCallback(t, "github", client) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + location, err := resp.Location() + require.NoError(t, err) + require.Equal(t, "/gitauth", location.Path) + + // Callback again to simulate updating the token. + resp = gitAuthCallback(t, "github", client) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + }) + t.Run("FullFlow", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + GitAuthConfigs: []*gitauth.Config{{ + OAuth2Config: &oauth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.GitProviderGitHub, + }}, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + + token, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", false) + require.NoError(t, err) + require.NotEmpty(t, token.URL) + + // Start waiting for the token callback... + tokenChan := make(chan codersdk.WorkspaceAgentGitAuthResponse, 1) + go func() { + token, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", true) + assert.NoError(t, err) + tokenChan <- token + }() + + time.Sleep(250 * time.Millisecond) + + resp := gitAuthCallback(t, "github", client) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + token = <-tokenChan + require.Equal(t, "token", token.Username) + + token, err = agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", false) + require.NoError(t, err) + }) +} + +func gitAuthCallback(t *testing.T, id string, client *codersdk.Client) *http.Response { + client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + state := "somestate" + oauthURL, err := client.URL.Parse(fmt.Sprintf("/gitauth/%s/callback?code=asd&state=%s", id, state)) + require.NoError(t, err) + req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil) + require.NoError(t, err) + req.AddCookie(&http.Cookie{ + Name: codersdk.OAuth2StateKey, + Value: state, + }) + req.AddCookie(&http.Cookie{ + Name: codersdk.SessionTokenKey, + Value: client.SessionToken, + }) + res, err := client.HTTPClient.Do(req) + require.NoError(t, err) + t.Cleanup(func() { + _ = res.Body.Close() + }) + return res +} diff --git a/codersdk/flags.go b/codersdk/flags.go index bf407760bbfb8..72cef4039db45 100644 --- a/codersdk/flags.go +++ b/codersdk/flags.go @@ -26,6 +26,7 @@ type DeploymentFlags struct { PromAddress *StringFlag `json:"prom_address" typescript:",notnull"` PprofEnabled *BoolFlag `json:"pprof_enabled" typescript:",notnull"` PprofAddress *StringFlag `json:"pprof_address" typescript:",notnull"` + ConfigPath *StringFlag `json:"config_path" typescript:",notnull"` CacheDir *StringFlag `json:"cache_dir" typescript:",notnull"` InMemoryDatabase *BoolFlag `json:"in_memory_database" typescript:",notnull"` ProvisionerDaemonCount *IntFlag `json:"provisioner_daemon_count" typescript:",notnull"` diff --git a/codersdk/gitauth.go b/codersdk/gitsshkey.go similarity index 62% rename from codersdk/gitauth.go rename to codersdk/gitsshkey.go index 627fd93a8ac8d..e345a2733ab02 100644 --- a/codersdk/gitauth.go +++ b/codersdk/gitsshkey.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" "time" "github.com/google/uuid" @@ -71,42 +70,3 @@ func (c *Client) AgentGitSSHKey(ctx context.Context) (AgentGitSSHKey, error) { var agentgitsshkey AgentGitSSHKey return agentgitsshkey, json.NewDecoder(res.Body).Decode(&agentgitsshkey) } - -// 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. If the URL doesn't match -func (c *Client) WorkspaceAgentGitAuth(ctx context.Context, gitURL string, listen bool) (WorkspaceAgentGitAuthResponse, error) { - url := "/api/v2/workspaceagents/me/gitauth?url=" + url.QueryEscape(gitURL) - if listen { - url += "&listen" - } - res, err := c.Request(ctx, http.MethodGet, url, nil) - if err != nil { - return WorkspaceAgentGitAuthResponse{}, xerrors.Errorf("execute request: %w", err) - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return WorkspaceAgentGitAuthResponse{}, readBodyAsError(res) - } - - var authResp WorkspaceAgentGitAuthResponse - return authResp, json.NewDecoder(res.Body).Decode(&authResp) -} diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index c86944ae2b629..bde7fb79d13f3 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/cookiejar" "net/netip" + "net/url" "strconv" "time" @@ -642,3 +643,42 @@ 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. If the URL doesn't match +func (c *Client) WorkspaceAgentGitAuth(ctx context.Context, gitURL string, listen bool) (WorkspaceAgentGitAuthResponse, error) { + url := "/api/v2/workspaceagents/me/gitauth?url=" + url.QueryEscape(gitURL) + if listen { + url += "&listen" + } + res, err := c.Request(ctx, http.MethodGet, url, nil) + if err != nil { + return WorkspaceAgentGitAuthResponse{}, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return WorkspaceAgentGitAuthResponse{}, readBodyAsError(res) + } + + var authResp WorkspaceAgentGitAuthResponse + return authResp, json.NewDecoder(res.Body).Decode(&authResp) +} diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index c0759aecafdaf..6ca1193264571 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -80,6 +80,9 @@ const AuthSettingsPage = lazy( const NetworkSettingsPage = lazy( () => import("./pages/DeploySettingsPage/NetworkSettingsPage"), ) +const GitAuthPage = lazy( + () => import("./pages/GitAuthPage/GitAuthPage") +) export const AppRouter: FC = () => { const xServices = useContext(XServiceContext) @@ -112,6 +115,14 @@ export const AppRouter: FC = () => { } /> + + + + } + /> } -// From codersdk/gitproviders.go -export interface WorkspaceAgentGitAuthRequest { - readonly url: string -} - -// From codersdk/gitproviders.go +// From codersdk/gitauth.go export interface WorkspaceAgentGitAuthResponse { readonly username: string readonly password: string diff --git a/site/src/pages/GitAuthPage/GitAuthPage.tsx b/site/src/pages/GitAuthPage/GitAuthPage.tsx new file mode 100644 index 0000000000000..fa0980d76caa4 --- /dev/null +++ b/site/src/pages/GitAuthPage/GitAuthPage.tsx @@ -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 ( + + +

+ Return to your terminal to keep coding. +

+ +
+ +
+
+ ) +} + +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), + }, +})) From f86e26aef04a3200060a69e43c7258d27cdce66b Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 20 Oct 2022 18:04:00 +0000 Subject: [PATCH 05/17] Update typesgen --- site/src/api/typesGenerated.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ebce4e95e9eaf..6df36eaa51251 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -18,7 +18,7 @@ export interface AddLicenseRequest { readonly license: string } -// From codersdk/gitauth.go +// From codersdk/gitsshkey.go export interface AgentGitSSHKey { readonly public_key: string readonly private_key: string @@ -282,6 +282,7 @@ export interface DeploymentFlags { readonly prom_address: StringFlag readonly pprof_enabled: BoolFlag readonly pprof_address: StringFlag + readonly config_path: StringFlag readonly cache_dir: StringFlag readonly in_memory_database: BoolFlag readonly provisioner_daemon_count: IntFlag @@ -363,7 +364,7 @@ export interface GetAppHostResponse { readonly host: string } -// From codersdk/gitauth.go +// From codersdk/gitsshkey.go export interface GitSSHKey { readonly user_id: string readonly created_at: string @@ -784,7 +785,7 @@ export interface WorkspaceAgent { readonly latency?: Record } -// From codersdk/gitauth.go +// From codersdk/workspaceagents.go export interface WorkspaceAgentGitAuthResponse { readonly username: string readonly password: string From 0a2e22271e748cb2f4a869956d5a20e31bbaa56d Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 23 Oct 2022 01:49:27 +0000 Subject: [PATCH 06/17] Convert configuration format for git auth --- cli/config/server.go | 56 ------------- cli/config/server_test.go | 40 --------- cli/deployment/config.go | 44 ++++++++++ cli/deployment/config_test.go | 39 +++++++++ cli/server.go | 28 ++++--- coderd/gitauth/config.go | 15 +--- coderd/gitauth/config_test.go | 16 ++-- coderd/userauth_test.go | 1 + codersdk/deploymentconfig.go | 115 ++++++++++++++------------ site/src/AppRouter.tsx | 8 +- site/src/api/typesGenerated.ts | 18 +++- site/src/components/Navbar/Navbar.tsx | 2 +- 12 files changed, 196 insertions(+), 186 deletions(-) delete mode 100644 cli/config/server.go delete mode 100644 cli/config/server_test.go diff --git a/cli/config/server.go b/cli/config/server.go deleted file mode 100644 index 559bf81594ee4..0000000000000 --- a/cli/config/server.go +++ /dev/null @@ -1,56 +0,0 @@ -package config - -import ( - "errors" - "net/url" - "os" - - "github.com/spf13/cobra" - "gopkg.in/yaml.v3" - - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/gitauth" - - _ "embed" -) - -//go:embed server.yaml -var defaultServer string - -// Server represents a parsed server configuration. -type Server struct { - GitAuth []*gitauth.Config -} - -// ParseServer creates or consumes a server config by path. -// If one does not exist, it will create one. If it fails to create, -// a warning will appear but the server will not fail to start. -// This is to prevent blocking execution on readonly file-systems -// that didn't provide a default config. -func ParseServer(cmd *cobra.Command, accessURL *url.URL, path string) (*Server, error) { - _, err := os.Stat(path) - if errors.Is(err, os.ErrNotExist) { - err = os.WriteFile(path, []byte(defaultServer), 0600) - if err != nil { - cmd.Printf("%s Unable to write the default config file: %s", cliui.Styles.Warn.Render("Warning:"), err) - } - } - data, err := os.ReadFile(path) - if err != nil { - data = []byte(defaultServer) - } - var server struct { - GitAuth []*gitauth.YAML `yaml:"gitauth"` - } - err = yaml.Unmarshal(data, &server) - if err != nil { - return nil, err - } - configs, err := gitauth.ConvertYAML(server.GitAuth, accessURL) - if err != nil { - return nil, err - } - return &Server{ - GitAuth: configs, - }, nil -} diff --git a/cli/config/server_test.go b/cli/config/server_test.go deleted file mode 100644 index 3fda519fbdaf4..0000000000000 --- a/cli/config/server_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package config_test - -import ( - "net/url" - "os" - "path/filepath" - "testing" - - "github.com/spf13/cobra" - "github.com/stretchr/testify/require" - - "github.com/coder/coder/cli/config" -) - -func TestServer(t *testing.T) { - t.Parallel() - t.Run("WritesDefault", func(t *testing.T) { - t.Parallel() - path := filepath.Join(t.TempDir(), "server.yaml") - _, err := config.ParseServer(&cobra.Command{}, &url.URL{}, path) - require.NoError(t, err) - data, err := os.ReadFile(path) - require.NoError(t, err) - require.Greater(t, len(data), 0) - }) - t.Run("Filled", func(t *testing.T) { - t.Parallel() - path := filepath.Join(t.TempDir(), "server.yaml") - err := os.WriteFile(path, []byte(` -gitauth: -- type: github - client_id: xxx - client_secret: xxx -`), 0600) - require.NoError(t, err) - cfg, err := config.ParseServer(&cobra.Command{}, &url.URL{}, path) - require.NoError(t, err) - require.Len(t, cfg.GitAuth, 1) - }) -} diff --git a/cli/deployment/config.go b/cli/deployment/config.go index 1272ab8529ee7..942c333d14a97 100644 --- a/cli/deployment/config.go +++ b/cli/deployment/config.go @@ -91,6 +91,12 @@ func newConfig() codersdk.DeploymentConfig { Usage: "Path to read a DERP mapping from. See: https://tailscale.com/kb/1118/custom-derp-servers/", Flag: "derp-config-path", }, + GitAuth: codersdk.DeploymentConfigField[[]codersdk.DeploymentConfigGitAuth]{ + Key: "gitauth", + Usage: "Git Authentication", + Flag: "gitauth", + Value: []codersdk.DeploymentConfigGitAuth{}, + }, PrometheusEnable: codersdk.DeploymentConfigField[bool]{ Key: "prometheus.enable", Usage: "Serve prometheus metrics on the address defined by prometheus address.", @@ -361,6 +367,9 @@ func Config(flagset *pflag.FlagSet, vip *viper.Viper) (codersdk.DeploymentConfig value = append(value, strings.Split(entry, ",")...) } fve.FieldByName("Value").Set(reflect.ValueOf(value)) + case []codersdk.DeploymentConfigGitAuth: + values := readSliceFromViper[codersdk.DeploymentConfigGitAuth](vip, key, value) + fve.FieldByName("Value").Set(reflect.ValueOf(values)) default: return dc, xerrors.Errorf("unsupported type %T", value) } @@ -369,12 +378,47 @@ func Config(flagset *pflag.FlagSet, vip *viper.Viper) (codersdk.DeploymentConfig return dc, nil } +// readSliceFromViper reads a typed mapping from the key provided. +// This enables environment variables like CODER_GITAUTH__CLIENT_ID. +func readSliceFromViper[T any](vip *viper.Viper, key string, value any) []T { + elementType := reflect.TypeOf(value).Elem() + returnValues := make([]T, 0) + for entry := 0; true; entry++ { + // Only create an instance when the entry exists in viper... + // otherwise we risk + var instance *reflect.Value + for i := 0; i < elementType.NumField(); i++ { + fve := elementType.Field(i) + prop := fve.Tag.Get("json") + // For fields that are omitted in JSON, we use a YAML tag. + if prop == "-" { + prop = fve.Tag.Get("yaml") + } + value := vip.Get(fmt.Sprintf("%s.%d.%s", key, entry, prop)) + if value == nil { + continue + } + if instance == nil { + newType := reflect.Indirect(reflect.New(elementType)) + instance = &newType + } + instance.Field(i).Set(reflect.ValueOf(value)) + } + if instance == nil { + break + } + returnValues = append(returnValues, instance.Interface().(T)) + } + return returnValues +} + func NewViper() *viper.Viper { dc := newConfig() v := viper.New() v.SetEnvPrefix("coder") v.AutomaticEnv() v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_")) + v.SetTypeByDefaultValue(true) dcv := reflect.ValueOf(dc) t := dcv.Type() diff --git a/cli/deployment/config_test.go b/cli/deployment/config_test.go index da67f486c8153..0749ccdfb83c5 100644 --- a/cli/deployment/config_test.go +++ b/cli/deployment/config_test.go @@ -148,6 +148,45 @@ func TestConfig(t *testing.T) { require.Equal(t, []string{"coder"}, config.OAuth2GithubAllowedTeams.Value) require.Equal(t, config.OAuth2GithubAllowSignups.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.DeploymentConfigGitAuth{{ + ID: "hello", + Type: "github", + ClientID: "client", + ClientSecret: "secret", + AuthURL: "https://auth.com", + TokenURL: "https://token.com", + Regex: "github.com", + }, { + ID: "another", + Type: "gitlab", + ClientID: "client-2", + ClientSecret: "secret-2", + AuthURL: "https://auth-2.com", + TokenURL: "https://token-2.com", + Regex: "gitlab.com", + }}, config.GitAuth.Value) + }, }} { tc := tc t.Run(tc.Name, func(t *testing.T) { diff --git a/cli/server.go b/cli/server.go index d4cf7b2c7d5db..2a34f66a4be86 100644 --- a/cli/server.go +++ b/cli/server.go @@ -55,6 +55,7 @@ import ( "github.com/coder/coder/coderd/database/databasefake" "github.com/coder/coder/coderd/database/migrations" "github.com/coder/coder/coderd/devtunnel" + "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/prometheusmetrics" @@ -325,17 +326,22 @@ 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) + } + options := &coderd.Options{ - AccessURL: accessURLParsed, - AppHostname: appHostname, - AppHostnameRegex: appHostnameRegex, - Logger: logger.Named("coderd"), - Database: databasefake.New(), - DERPMap: derpMap, - Pubsub: database.NewPubsubInMemory(), - CacheDir: cfg.CacheDirectory.Value, - GoogleTokenValidator: googleTokenValidator, - // GitAuthConfigs: serverConfig.GitAuth, + AccessURL: accessURLParsed, + AppHostname: appHostname, + AppHostnameRegex: appHostnameRegex, + Logger: logger.Named("coderd"), + Database: databasefake.New(), + DERPMap: derpMap, + Pubsub: database.NewPubsubInMemory(), + CacheDir: cfg.CacheDirectory.Value, + GoogleTokenValidator: googleTokenValidator, + GitAuthConfigs: gitAuthConfigs, SecureAuthCookie: cfg.SecureAuthCookie.Value, SSHKeygenAlgorithm: sshKeygenAlgorithm, TracerProvider: tracerProvider, @@ -617,7 +623,7 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co // This is helpful for tests, but can be silently ignored. // Coder may be ran as users that don't have permission to write in the homedir, // such as via the systemd service. - _ = cfg.URL().Write(client.URL.String()) + _ = config.URL().Write(client.URL.String()) // Currently there is no way to ask the server to shut // itself down, so any exit signal will result in a non-zero diff --git a/coderd/gitauth/config.go b/coderd/gitauth/config.go index 0c507e0f478db..a4aed49343462 100644 --- a/coderd/gitauth/config.go +++ b/coderd/gitauth/config.go @@ -24,20 +24,9 @@ type Config struct { Type codersdk.GitProvider } -// YAML represents a serializable config. -type YAML struct { - ID string `yaml:"id"` - Type string `yaml:"type"` - ClientID string `yaml:"client_id"` - ClientSecret string `yaml:"client_secret"` - AuthURL string `yaml:"auth_url"` - TokenURL string `yaml:"token_url"` - Regex string `yaml:"regex"` -} - -// ConvertYAML converts the YAML configuration entry to the +// ConvertConfig converts the YAML configuration entry to the // parsed and ready-to-consume provider type. -func ConvertYAML(entries []*YAML, accessURL *url.URL) ([]*Config, error) { +func ConvertConfig(entries []codersdk.DeploymentConfigGitAuth, accessURL *url.URL) ([]*Config, error) { ids := map[string]struct{}{} configs := []*Config{} for _, entry := range entries { diff --git a/coderd/gitauth/config_test.go b/coderd/gitauth/config_test.go index c9494a4882329..6765c93eeb648 100644 --- a/coderd/gitauth/config_test.go +++ b/coderd/gitauth/config_test.go @@ -14,38 +14,38 @@ func TestConvertYAML(t *testing.T) { t.Parallel() for _, tc := range []struct { Name string - Input []*gitauth.YAML + Input []codersdk.DeploymentConfigGitAuth Output []*gitauth.Config Error string }{{ Name: "InvalidType", - Input: []*gitauth.YAML{{ + Input: []codersdk.DeploymentConfigGitAuth{{ Type: "moo", }}, Error: "unknown git provider type", }, { Name: "InvalidID", - Input: []*gitauth.YAML{{ + Input: []codersdk.DeploymentConfigGitAuth{{ Type: codersdk.GitProviderGitHub, ID: "$hi$", }}, Error: "doesn't have a valid id", }, { Name: "NoClientID", - Input: []*gitauth.YAML{{ + Input: []codersdk.DeploymentConfigGitAuth{{ Type: codersdk.GitProviderGitHub, }}, Error: "client_id must be provided", }, { Name: "NoClientSecret", - Input: []*gitauth.YAML{{ + Input: []codersdk.DeploymentConfigGitAuth{{ Type: codersdk.GitProviderGitHub, ClientID: "example", }}, Error: "client_secret must be provided", }, { Name: "DuplicateType", - Input: []*gitauth.YAML{{ + Input: []codersdk.DeploymentConfigGitAuth{{ Type: codersdk.GitProviderGitHub, ClientID: "example", ClientSecret: "example", @@ -55,7 +55,7 @@ func TestConvertYAML(t *testing.T) { Error: "multiple github git auth providers provided", }, { Name: "InvalidRegex", - Input: []*gitauth.YAML{{ + Input: []codersdk.DeploymentConfigGitAuth{{ Type: codersdk.GitProviderGitHub, ClientID: "example", ClientSecret: "example", @@ -66,7 +66,7 @@ func TestConvertYAML(t *testing.T) { tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() - output, err := gitauth.ConvertYAML(tc.Input, &url.URL{}) + output, err := gitauth.ConvertConfig(tc.Input, &url.URL{}) if tc.Error != "" { require.Error(t, err) require.Contains(t, err.Error(), tc.Error) diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 3e12869810b70..d21808052e885 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -9,6 +9,7 @@ import ( "net/url" "strings" "testing" + "time" "github.com/coreos/go-oidc/v3/oidc" "github.com/golang-jwt/jwt" diff --git a/codersdk/deploymentconfig.go b/codersdk/deploymentconfig.go index 1ab43ddbb4289..b11c054adfb61 100644 --- a/codersdk/deploymentconfig.go +++ b/codersdk/deploymentconfig.go @@ -12,61 +12,62 @@ import ( // DeploymentConfig is the central configuration for the coder server. // Secret values should specify `json:"-"` to prevent them from being returned by the API. type DeploymentConfig struct { - AccessURL DeploymentConfigField[string] `json:"access_url"` - WildcardAccessURL DeploymentConfigField[string] `json:"wildcard_access_url"` - Address DeploymentConfigField[string] `json:"address"` - AutobuildPollInterval DeploymentConfigField[time.Duration] `json:"autobuild_poll_interval"` - DERPServerEnable DeploymentConfigField[bool] `json:"derp_server_enabled"` - DERPServerRegionID DeploymentConfigField[int] `json:"derp_server_region_id"` - DERPServerRegionCode DeploymentConfigField[string] `json:"derp_server_region_code"` - DERPServerRegionName DeploymentConfigField[string] `json:"derp_server_region_name"` - DERPServerSTUNAddresses DeploymentConfigField[[]string] `json:"derp_server_stun_address"` - DERPServerRelayURL DeploymentConfigField[string] `json:"derp_server_relay_address"` - DERPConfigURL DeploymentConfigField[string] `json:"derp_config_url"` - DERPConfigPath DeploymentConfigField[string] `json:"derp_config_path"` - PrometheusEnable DeploymentConfigField[bool] `json:"prometheus_enabled"` - PrometheusAddress DeploymentConfigField[string] `json:"prometheus_address"` - PprofEnable DeploymentConfigField[bool] `json:"pprof_enabled"` - PprofAddress DeploymentConfigField[string] `json:"pprof_address"` - CacheDirectory DeploymentConfigField[string] `json:"cache_directory"` - InMemoryDatabase DeploymentConfigField[bool] `json:"in_memory_database"` - ProvisionerDaemons DeploymentConfigField[int] `json:"provisioner_daemon_count"` - PostgresURL DeploymentConfigField[string] `json:"-"` - OAuth2GithubClientID DeploymentConfigField[string] `json:"oauth2_github_client_id"` - OAuth2GithubClientSecret DeploymentConfigField[string] `json:"-"` - OAuth2GithubAllowedOrgs DeploymentConfigField[[]string] `json:"oauth2_github_allowed_orgs"` - OAuth2GithubAllowedTeams DeploymentConfigField[[]string] `json:"oauth2_github_allowed_teams"` - OAuth2GithubAllowSignups DeploymentConfigField[bool] `json:"oauth2_github_allow_signups"` - OAuth2GithubEnterpriseBaseURL DeploymentConfigField[string] `json:"oauth2_github_enterprise_base_url"` - OIDCAllowSignups DeploymentConfigField[bool] `json:"oidc_allow_signups"` - OIDCClientID DeploymentConfigField[string] `json:"oidc_client_id"` - OIDCClientSecret DeploymentConfigField[string] `json:"-"` - OIDCEmailDomain DeploymentConfigField[string] `json:"oidc_email_domain"` - OIDCIssuerURL DeploymentConfigField[string] `json:"oidc_issuer_url"` - OIDCScopes DeploymentConfigField[[]string] `json:"oidc_scopes"` - TelemetryEnable DeploymentConfigField[bool] `json:"telemetry_enable"` - TelemetryTrace DeploymentConfigField[bool] `json:"telemetry_trace_enable"` - TelemetryURL DeploymentConfigField[string] `json:"telemetry_url"` - TLSEnable DeploymentConfigField[bool] `json:"tls_enable"` - TLSCertFiles DeploymentConfigField[[]string] `json:"tls_cert_files"` - TLSClientCAFile DeploymentConfigField[string] `json:"tls_client_ca_file"` - TLSClientAuth DeploymentConfigField[string] `json:"tls_client_auth"` - TLSKeyFiles DeploymentConfigField[[]string] `json:"tls_key_files"` - TLSMinVersion DeploymentConfigField[string] `json:"tls_min_version"` - TraceEnable DeploymentConfigField[bool] `json:"trace_enable"` - SecureAuthCookie DeploymentConfigField[bool] `json:"secure_auth_cookie"` - SSHKeygenAlgorithm DeploymentConfigField[string] `json:"ssh_keygen_algorithm"` - AutoImportTemplates DeploymentConfigField[[]string] `json:"auto_import_templates"` - MetricsCacheRefreshInterval DeploymentConfigField[time.Duration] `json:"metrics_cache_refresh_interval"` - AgentStatRefreshInterval DeploymentConfigField[time.Duration] `json:"agent_stat_refresh_interval"` - AuditLogging DeploymentConfigField[bool] `json:"audit_logging"` - BrowserOnly DeploymentConfigField[bool] `json:"browser_only"` - SCIMAPIKey DeploymentConfigField[string] `json:"-"` - UserWorkspaceQuota DeploymentConfigField[int] `json:"user_workspace_quota"` + AccessURL DeploymentConfigField[string] `json:"access_url"` + WildcardAccessURL DeploymentConfigField[string] `json:"wildcard_access_url"` + Address DeploymentConfigField[string] `json:"address"` + AutobuildPollInterval DeploymentConfigField[time.Duration] `json:"autobuild_poll_interval"` + DERPServerEnable DeploymentConfigField[bool] `json:"derp_server_enabled"` + DERPServerRegionID DeploymentConfigField[int] `json:"derp_server_region_id"` + DERPServerRegionCode DeploymentConfigField[string] `json:"derp_server_region_code"` + DERPServerRegionName DeploymentConfigField[string] `json:"derp_server_region_name"` + DERPServerSTUNAddresses DeploymentConfigField[[]string] `json:"derp_server_stun_address"` + DERPServerRelayURL DeploymentConfigField[string] `json:"derp_server_relay_address"` + DERPConfigURL DeploymentConfigField[string] `json:"derp_config_url"` + DERPConfigPath DeploymentConfigField[string] `json:"derp_config_path"` + PrometheusEnable DeploymentConfigField[bool] `json:"prometheus_enabled"` + PrometheusAddress DeploymentConfigField[string] `json:"prometheus_address"` + PprofEnable DeploymentConfigField[bool] `json:"pprof_enabled"` + PprofAddress DeploymentConfigField[string] `json:"pprof_address"` + CacheDirectory DeploymentConfigField[string] `json:"cache_directory"` + GitAuth DeploymentConfigField[[]DeploymentConfigGitAuth] `json:"gitauth"` + InMemoryDatabase DeploymentConfigField[bool] `json:"in_memory_database"` + ProvisionerDaemons DeploymentConfigField[int] `json:"provisioner_daemon_count"` + PostgresURL DeploymentConfigField[string] `json:"-"` + OAuth2GithubClientID DeploymentConfigField[string] `json:"oauth2_github_client_id"` + OAuth2GithubClientSecret DeploymentConfigField[string] `json:"-"` + OAuth2GithubAllowedOrgs DeploymentConfigField[[]string] `json:"oauth2_github_allowed_orgs"` + OAuth2GithubAllowedTeams DeploymentConfigField[[]string] `json:"oauth2_github_allowed_teams"` + OAuth2GithubAllowSignups DeploymentConfigField[bool] `json:"oauth2_github_allow_signups"` + OAuth2GithubEnterpriseBaseURL DeploymentConfigField[string] `json:"oauth2_github_enterprise_base_url"` + OIDCAllowSignups DeploymentConfigField[bool] `json:"oidc_allow_signups"` + OIDCClientID DeploymentConfigField[string] `json:"oidc_client_id"` + OIDCClientSecret DeploymentConfigField[string] `json:"-"` + OIDCEmailDomain DeploymentConfigField[string] `json:"oidc_email_domain"` + OIDCIssuerURL DeploymentConfigField[string] `json:"oidc_issuer_url"` + OIDCScopes DeploymentConfigField[[]string] `json:"oidc_scopes"` + TelemetryEnable DeploymentConfigField[bool] `json:"telemetry_enable"` + TelemetryTrace DeploymentConfigField[bool] `json:"telemetry_trace_enable"` + TelemetryURL DeploymentConfigField[string] `json:"telemetry_url"` + TLSEnable DeploymentConfigField[bool] `json:"tls_enable"` + TLSCertFiles DeploymentConfigField[[]string] `json:"tls_cert_files"` + TLSClientCAFile DeploymentConfigField[string] `json:"tls_client_ca_file"` + TLSClientAuth DeploymentConfigField[string] `json:"tls_client_auth"` + TLSKeyFiles DeploymentConfigField[[]string] `json:"tls_key_files"` + TLSMinVersion DeploymentConfigField[string] `json:"tls_min_version"` + TraceEnable DeploymentConfigField[bool] `json:"trace_enable"` + SecureAuthCookie DeploymentConfigField[bool] `json:"secure_auth_cookie"` + SSHKeygenAlgorithm DeploymentConfigField[string] `json:"ssh_keygen_algorithm"` + AutoImportTemplates DeploymentConfigField[[]string] `json:"auto_import_templates"` + MetricsCacheRefreshInterval DeploymentConfigField[time.Duration] `json:"metrics_cache_refresh_interval"` + AgentStatRefreshInterval DeploymentConfigField[time.Duration] `json:"agent_stat_refresh_interval"` + AuditLogging DeploymentConfigField[bool] `json:"audit_logging"` + BrowserOnly DeploymentConfigField[bool] `json:"browser_only"` + SCIMAPIKey DeploymentConfigField[string] `json:"-"` + UserWorkspaceQuota DeploymentConfigField[int] `json:"user_workspace_quota"` } type Flaggable interface { - string | bool | int | time.Duration | []string + string | time.Duration | bool | int | []string | []DeploymentConfigGitAuth } type DeploymentConfigField[T Flaggable] struct { @@ -95,3 +96,13 @@ func (c *Client) DeploymentConfig(ctx context.Context) (DeploymentConfig, error) var df DeploymentConfig return df, json.NewDecoder(res.Body).Decode(&df) } + +type DeploymentConfigGitAuth 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"` +} diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 6106de6fa4d7c..6ca1193264571 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -267,7 +267,7 @@ export const AppRouter: FC = () => { element={ @@ -281,7 +281,7 @@ export const AppRouter: FC = () => { element={ @@ -295,7 +295,7 @@ export const AppRouter: FC = () => { element={ @@ -309,7 +309,7 @@ export const AppRouter: FC = () => { element={ diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 39abf954f7de3..d674ca8dbebed 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -270,6 +270,7 @@ export interface DeploymentConfig { readonly pprof_enabled: DeploymentConfigField readonly pprof_address: DeploymentConfigField readonly cache_directory: DeploymentConfigField + readonly gitauth: DeploymentConfigField readonly in_memory_database: DeploymentConfigField readonly provisioner_daemon_count: DeploymentConfigField readonly oauth2_github_client_id: DeploymentConfigField @@ -314,6 +315,16 @@ export interface DeploymentConfigField { readonly value: T } +// From codersdk/deploymentconfig.go +export interface DeploymentConfigGitAuth { + readonly id: string + readonly type: string + readonly client_id: string + readonly auth_url: string + readonly token_url: string + readonly regex: string +} + // From codersdk/features.go export interface Entitlements { readonly features: Record @@ -947,4 +958,9 @@ 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[] + | DeploymentConfigGitAuth[] diff --git a/site/src/components/Navbar/Navbar.tsx b/site/src/components/Navbar/Navbar.tsx index 56d763b4a66f1..95355e5a1dbe7 100644 --- a/site/src/components/Navbar/Navbar.tsx +++ b/site/src/components/Navbar/Navbar.tsx @@ -22,7 +22,7 @@ export const Navbar: React.FC = () => { featureVisibility[FeatureNames.AuditLog] && Boolean(permissions?.viewAuditLog) const canViewDeployment = - experimental && Boolean(permissions?.viewDeploymentConfig) + experimental && Boolean(permissions?.viewDeploymentFlags) const onSignOut = () => authSend("SIGN_OUT") return ( From 7237d9b5bc2f446e9fd99beacddc3ac406d284f2 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 23 Oct 2022 16:21:31 +0000 Subject: [PATCH 07/17] Fix unclosed database conn --- cli/server.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/server.go b/cli/server.go index 2a34f66a4be86..3eab9e43e3f2b 100644 --- a/cli/server.go +++ b/cli/server.go @@ -424,6 +424,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!") From 439d3bc3ae3d23cac762a66d51d225adf95b163d Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 23 Oct 2022 19:24:27 +0000 Subject: [PATCH 08/17] Add overriding VS Code configuration --- .vscode/settings.json | 1 + coderd/gitauth/vscode.go | 76 +++++++++++++++++++++++++++++++++++ coderd/gitauth/vscode_test.go | 64 +++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + 5 files changed, 144 insertions(+) create mode 100644 coderd/gitauth/vscode.go create mode 100644 coderd/gitauth/vscode_test.go diff --git a/.vscode/settings.json b/.vscode/settings.json index e0ce7f5382b47..2f1443066768d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ + "afero", "apps", "ASKPASS", "awsidentity", diff --git a/coderd/gitauth/vscode.go b/coderd/gitauth/vscode.go new file mode 100644 index 0000000000000..5a30cd07cb170 --- /dev/null +++ b/coderd/gitauth/vscode.go @@ -0,0 +1,76 @@ +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 = afero.WriteFile(fs, configPath, data, 0600) + if err != nil { + return xerrors.Errorf("write %q: %w", configPath, err) + } + continue + } + + data, err := afero.ReadFile(fs, configPath) + if err != nil { + return xerrors.Errorf("read %q: %w", configPath, err) + } + mapping := map[string]interface{}{} + err = json.Unmarshal(data, &mapping) + if err != nil { + return xerrors.Errorf("unmarshal %q: %w", configPath, err) + } + mutate(mapping) + data, err = json.MarshalIndent(mapping, "", "\t") + if err != nil { + return xerrors.Errorf("marshal %q: %w", configPath, err) + } + err = afero.WriteFile(fs, configPath, data, 0600) + if err != nil { + return xerrors.Errorf("write %q: %w", configPath, err) + } + } + return nil +} diff --git a/coderd/gitauth/vscode_test.go b/coderd/gitauth/vscode_test.go new file mode 100644 index 0000000000000..fca7994d93134 --- /dev/null +++ b/coderd/gitauth/vscode_test.go @@ -0,0 +1,64 @@ +package gitauth_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/adrg/xdg" + "github.com/spf13/afero" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/gitauth" +) + +func TestOverrideVSCodeConfigs(t *testing.T) { + t.Parallel() + home, err := os.UserHomeDir() + require.NoError(t, err) + configPaths := []string{ + filepath.Join(xdg.DataHome, "code-server", "Machine", "settings.json"), + filepath.Join(home, ".vscode-server", "data", "Machine", "settings.json"), + } + t.Run("Create", func(t *testing.T) { + t.Parallel() + fs := afero.NewMemMapFs() + err := gitauth.OverrideVSCodeConfigs(fs) + require.NoError(t, err) + for _, configPath := range configPaths { + data, err := afero.ReadFile(fs, configPath) + require.NoError(t, err) + mapping := map[string]interface{}{} + err = json.Unmarshal(data, &mapping) + require.NoError(t, err) + require.Equal(t, false, mapping["git.useIntegratedAskPass"]) + require.Equal(t, false, mapping["github.gitAuthentication"]) + } + }) + t.Run("Append", func(t *testing.T) { + t.Parallel() + fs := afero.NewMemMapFs() + mapping := map[string]interface{}{ + "hotdogs": "something", + } + data, err := json.Marshal(mapping) + require.NoError(t, err) + for _, configPath := range configPaths { + err = afero.WriteFile(fs, configPath, data, 0600) + require.NoError(t, err) + } + err = gitauth.OverrideVSCodeConfigs(fs) + require.NoError(t, err) + for _, configPath := range configPaths { + data, err := afero.ReadFile(fs, configPath) + require.NoError(t, err) + mapping := map[string]interface{}{} + err = json.Unmarshal(data, &mapping) + require.NoError(t, err) + require.Equal(t, false, mapping["git.useIntegratedAskPass"]) + require.Equal(t, false, mapping["github.gitAuthentication"]) + require.Equal(t, "something", mapping["hotdogs"]) + } + }) +} diff --git a/go.mod b/go.mod index b0ccb136c8dd4..72f9e7d84141d 100644 --- a/go.mod +++ b/go.mod @@ -170,6 +170,7 @@ require ( ) require ( + github.com/adrg/xdg v0.4.0 // indirect github.com/fsnotify/fsnotify v1.5.4 // indirect github.com/magiconair/properties v1.8.6 // indirect github.com/pelletier/go-toml v1.9.5 // indirect diff --git a/go.sum b/go.sum index 1db86e43dbe65..1442be339a2dd 100644 --- a/go.sum +++ b/go.sum @@ -159,6 +159,8 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= +github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= +github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= From a296e31a0fba5e79dab26ae47640490cef4670fe Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 23 Oct 2022 22:26:32 +0000 Subject: [PATCH 09/17] Fix Git screen --- site/src/pages/GitAuthPage/GitAuthPage.stories.tsx | 12 ++++++++++++ site/src/pages/GitAuthPage/GitAuthPage.tsx | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 site/src/pages/GitAuthPage/GitAuthPage.stories.tsx diff --git a/site/src/pages/GitAuthPage/GitAuthPage.stories.tsx b/site/src/pages/GitAuthPage/GitAuthPage.stories.tsx new file mode 100644 index 0000000000000..422bf964de9bc --- /dev/null +++ b/site/src/pages/GitAuthPage/GitAuthPage.stories.tsx @@ -0,0 +1,12 @@ +import { ComponentMeta, Story } from "@storybook/react" +import GitAuthPage from "./GitAuthPage" + +export default { + title: "pages/GitAuthPage", + component: GitAuthPage, +} as ComponentMeta + +const Template: Story = (args) => + +export const Default = Template.bind({}) +Default.args = {} diff --git a/site/src/pages/GitAuthPage/GitAuthPage.tsx b/site/src/pages/GitAuthPage/GitAuthPage.tsx index fa0980d76caa4..3b6866f56214f 100644 --- a/site/src/pages/GitAuthPage/GitAuthPage.tsx +++ b/site/src/pages/GitAuthPage/GitAuthPage.tsx @@ -10,9 +10,9 @@ const GitAuthPage: React.FC = () => { return ( - +

- Return to your terminal to keep coding. + Your Git authentication token will be refreshed to keep you signed in.

From 03e6e620e1f32e2077d4461f284dc12a2a950033 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 24 Oct 2022 14:33:18 +0000 Subject: [PATCH 10/17] Write VS Code special configuration if providers exist --- agent/agent.go | 15 +++++++++++++++ agent/agent_test.go | 33 +++++++++++++++++++++++++++++++++ coderd/workspaceagents.go | 1 + codersdk/workspaceagents.go | 4 ++++ 4 files changed, 53 insertions(+) diff --git a/agent/agent.go b/agent/agent.go index 355f5c0800f79..66e009a207e5c 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -26,6 +26,7 @@ import ( "github.com/gliderlabs/ssh" "github.com/google/uuid" "github.com/pkg/sftp" + "github.com/spf13/afero" "go.uber.org/atomic" gossh "golang.org/x/crypto/ssh" "golang.org/x/xerrors" @@ -35,6 +36,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/agent/usershell" "github.com/coder/coder/buildinfo" + "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/codersdk" "github.com/coder/coder/pty" "github.com/coder/coder/tailnet" @@ -53,6 +55,7 @@ const ( ) type Options struct { + Filesystem afero.Fs ExchangeToken func(ctx context.Context) error Client Client ReconnectingPTYTimeout time.Duration @@ -72,6 +75,9 @@ func New(options Options) io.Closer { if options.ReconnectingPTYTimeout == 0 { options.ReconnectingPTYTimeout = 5 * time.Minute } + if options.Filesystem == nil { + options.Filesystem = afero.NewOsFs() + } ctx, cancelFunc := context.WithCancel(context.Background()) server := &agent{ reconnectingPTYTimeout: options.ReconnectingPTYTimeout, @@ -81,6 +87,7 @@ func New(options Options) io.Closer { envVars: options.EnvironmentVariables, client: options.Client, exchangeToken: options.ExchangeToken, + filesystem: options.Filesystem, stats: &Stats{}, } server.init(ctx) @@ -91,6 +98,7 @@ type agent struct { logger slog.Logger client Client exchangeToken func(ctx context.Context) error + filesystem afero.Fs reconnectingPTYs sync.Map reconnectingPTYTimeout time.Duration @@ -171,6 +179,13 @@ func (a *agent) run(ctx context.Context) error { }() } + if metadata.GitAuthConfigs > 0 { + err = gitauth.OverrideVSCodeConfigs(a.filesystem) + if err != nil { + return xerrors.Errorf("override vscode configuration for git auth: %w", err) + } + } + // This automatically closes when the context ends! appReporterCtx, appReporterCtxCancel := context.WithCancel(ctx) defer appReporterCtxCancel() diff --git a/agent/agent_test.go b/agent/agent_test.go index b392d6cefa5cd..805a2bc119d2d 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -27,6 +27,7 @@ import ( "github.com/google/uuid" "github.com/pion/udp" "github.com/pkg/sftp" + "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" @@ -543,6 +544,38 @@ func TestAgent(t *testing.T) { return initialized.Load() == 2 }, testutil.WaitShort, testutil.IntervalFast) }) + + t.Run("WriteVSCodeConfigs", func(t *testing.T) { + t.Parallel() + client := &client{ + t: t, + agentID: uuid.New(), + metadata: codersdk.WorkspaceAgentMetadata{ + GitAuthConfigs: 1, + }, + statsChan: make(chan *codersdk.AgentStats), + coordinator: tailnet.NewCoordinator(), + } + filesystem := afero.NewMemMapFs() + closer := agent.New(agent.Options{ + ExchangeToken: func(ctx context.Context) error { + return nil + }, + Client: client, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelInfo), + Filesystem: filesystem, + }) + t.Cleanup(func() { + _ = closer.Close() + }) + home, err := os.UserHomeDir() + require.NoError(t, err) + path := filepath.Join(home, ".vscode-server", "data", "Machine", "settings.json") + require.Eventually(t, func() bool { + _, err := filesystem.Stat(path) + return err == nil + }, testutil.WaitShort, testutil.IntervalFast) + }) } func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd { diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index bf7f76681596c..8ab7da8e450d3 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -87,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, diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 2da6317720a80..8b952cd9684a7 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -119,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"` From 6bd9733b81094981b0f831fd505c98fde1458c77 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 24 Oct 2022 16:00:26 +0000 Subject: [PATCH 11/17] Enable automatic cloning from VS Code --- cli/gitaskpass.go | 6 ++++++ cli/login.go | 11 +++++++++++ coderd/gitauth/vscode.go | 5 +++++ site/src/api/typesGenerated.ts | 4 ++-- 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/cli/gitaskpass.go b/cli/gitaskpass.go index 4e3ad34040988..b21de835a4f17 100644 --- a/cli/gitaskpass.go +++ b/cli/gitaskpass.go @@ -45,6 +45,12 @@ func gitAskpass() *cobra.Command { } if token.URL != "" { cmd.Printf("Visit the following URL to authenticate with Git:\n%s\n", token.URL) + + err = openURL(cmd, token.URL) + if err != nil { + return xerrors.Errorf("open url: %w", err) + } + for r := retry.New(time.Second, 10*time.Second); r.Wait(ctx); { token, err = client.WorkspaceAgentGitAuth(ctx, host, true) if err != nil { diff --git a/cli/login.go b/cli/login.go index 0667a251f38dc..22a8f621823fb 100644 --- a/cli/login.go +++ b/cli/login.go @@ -285,5 +285,16 @@ func openURL(cmd *cobra.Command, urlToOpen string) error { return exec.Command("cmd.exe", "/c", "start", strings.ReplaceAll(urlToOpen, "&", "^&")).Start() } + browserEnv := os.Getenv("BROWSER") + if browserEnv != "" { + browserSh := fmt.Sprintf("%s '%s'", browserEnv, urlToOpen) + cmd := exec.CommandContext(cmd.Context(), "sh", "-c", browserSh) + out, err := cmd.CombinedOutput() + if err != nil { + return xerrors.Errorf("failed to run %v (out: %q): %w", cmd.Args, out, err) + } + return nil + } + return browser.OpenURL(urlToOpen) } diff --git a/coderd/gitauth/vscode.go b/coderd/gitauth/vscode.go index 5a30cd07cb170..8ab178b080e15 100644 --- a/coderd/gitauth/vscode.go +++ b/coderd/gitauth/vscode.go @@ -46,6 +46,11 @@ func OverrideVSCodeConfigs(fs afero.Fs) error { 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) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5322d2d65614d..dbf502027d979 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -269,11 +269,11 @@ export interface DeploymentConfig { readonly prometheus_address: DeploymentConfigField readonly pprof_enabled: DeploymentConfigField readonly pprof_address: DeploymentConfigField - readonly proxy_trusted_headers: DeploymentConfigField - readonly proxy_trusted_origins: DeploymentConfigField readonly cache_directory: DeploymentConfigField readonly gitauth: DeploymentConfigField readonly in_memory_database: DeploymentConfigField + readonly proxy_trusted_headers: DeploymentConfigField + readonly proxy_trusted_origins: DeploymentConfigField readonly provisioner_daemon_count: DeploymentConfigField readonly oauth2_github_client_id: DeploymentConfigField readonly oauth2_github_allowed_orgs: DeploymentConfigField From 1e5c2f7915fc14e078bb80eefbd5e61106b71909 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 24 Oct 2022 16:36:08 +0000 Subject: [PATCH 12/17] Add tests for gitaskpass --- cli/gitaskpass.go | 25 ++++++++---- cli/gitaskpass_test.go | 93 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 7 deletions(-) diff --git a/cli/gitaskpass.go b/cli/gitaskpass.go index b21de835a4f17..088bfa41d7ab4 100644 --- a/cli/gitaskpass.go +++ b/cli/gitaskpass.go @@ -1,17 +1,23 @@ 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", @@ -22,7 +28,6 @@ func gitAskpass() *cobra.Command { ctx, stop := signal.NotifyContext(ctx, interruptSignals...) defer stop() - defer func() { if ctx.Err() != nil { err = ctx.Err() @@ -41,17 +46,23 @@ func gitAskpass() *cobra.Command { 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 != "" { - cmd.Printf("Visit the following URL to authenticate with Git:\n%s\n", token.URL) - - err = openURL(cmd, token.URL) - if err != nil { - return xerrors.Errorf("open url: %w", err) + if err := openURL(cmd, token.URL); err != nil { + cmd.Printf("Your browser has been opened to visit:\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(time.Second, 10*time.Second); r.Wait(ctx); { + for r := retry.New(250*time.Millisecond, 10*time.Second); r.Wait(ctx); { token, err = client.WorkspaceAgentGitAuth(ctx, host, true) if err != nil { continue diff --git a/cli/gitaskpass_test.go b/cli/gitaskpass_test.go index 763604961c19c..2541ae5918474 100644 --- a/cli/gitaskpass_test.go +++ b/cli/gitaskpass_test.go @@ -1 +1,94 @@ package cli_test + +import ( + "context" + "net/http" + "net/http/httptest" + "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 := codersdk.WorkspaceAgentGitAuthResponse{ + URL: "https://something.org", + } + poll := make(chan struct{}, 10) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Has("listen") { + poll <- struct{}{} + if resp.URL != "" { + httpapi.Write(context.Background(), w, http.StatusInternalServerError, resp) + return + } + } + httpapi.Write(context.Background(), w, http.StatusOK, resp) + })) + 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 = codersdk.WorkspaceAgentGitAuthResponse{ + Username: "username", + Password: "password", + } + pty.ExpectMatch("username") + }) +} From 4e4f0babbbfe0164a64685a9a83777daa97a9ee1 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 24 Oct 2022 16:49:27 +0000 Subject: [PATCH 13/17] Fix feature visibiliy --- cli/deployment/config.go | 2 +- site/src/AppRouter.tsx | 8 ++++---- site/src/components/Navbar/Navbar.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cli/deployment/config.go b/cli/deployment/config.go index 916c142c92892..14f800b487d81 100644 --- a/cli/deployment/config.go +++ b/cli/deployment/config.go @@ -93,7 +93,7 @@ func newConfig() codersdk.DeploymentConfig { }, GitAuth: codersdk.DeploymentConfigField[[]codersdk.DeploymentConfigGitAuth]{ Key: "gitauth", - Usage: "Git Authentication", + Usage: "Automatically authenticate Git inside workspaces.", Flag: "gitauth", Value: []codersdk.DeploymentConfigGitAuth{}, }, diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 6ca1193264571..6106de6fa4d7c 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -267,7 +267,7 @@ export const AppRouter: FC = () => { element={ @@ -281,7 +281,7 @@ export const AppRouter: FC = () => { element={ @@ -295,7 +295,7 @@ export const AppRouter: FC = () => { element={ @@ -309,7 +309,7 @@ export const AppRouter: FC = () => { element={ diff --git a/site/src/components/Navbar/Navbar.tsx b/site/src/components/Navbar/Navbar.tsx index 95355e5a1dbe7..56d763b4a66f1 100644 --- a/site/src/components/Navbar/Navbar.tsx +++ b/site/src/components/Navbar/Navbar.tsx @@ -22,7 +22,7 @@ export const Navbar: React.FC = () => { featureVisibility[FeatureNames.AuditLog] && Boolean(permissions?.viewAuditLog) const canViewDeployment = - experimental && Boolean(permissions?.viewDeploymentFlags) + experimental && Boolean(permissions?.viewDeploymentConfig) const onSignOut = () => authSend("SIGN_OUT") return ( From f2c098367af8fcddba4977a7b5a84789be635989 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 24 Oct 2022 19:22:42 +0000 Subject: [PATCH 14/17] Add banner for too many configurations --- codersdk/features.go | 2 + enterprise/cli/features_test.go | 18 +---- enterprise/coderd/coderd.go | 3 +- .../coderd/coderdenttest/coderdenttest.go | 7 ++ enterprise/coderd/license/license.go | 37 ++++++++- enterprise/coderd/license/license_test.go | 78 ++++++++++++++++--- 6 files changed, 115 insertions(+), 30 deletions(-) diff --git a/codersdk/features.go b/codersdk/features.go index 862411de62872..336c01cc9eda9 100644 --- a/codersdk/features.go +++ b/codersdk/features.go @@ -22,6 +22,7 @@ const ( FeatureWorkspaceQuota = "workspace_quota" FeatureTemplateRBAC = "template_rbac" FeatureHighAvailability = "high_availability" + FeatureMultipleGitAuth = "multiple_git_auth" ) var FeatureNames = []string{ @@ -32,6 +33,7 @@ var FeatureNames = []string{ FeatureWorkspaceQuota, FeatureTemplateRBAC, FeatureHighAvailability, + FeatureMultipleGitAuth, } type Feature struct { diff --git a/enterprise/cli/features_test.go b/enterprise/cli/features_test.go index 78b94a6509526..1708807ddf1dc 100644 --- a/enterprise/cli/features_test.go +++ b/enterprise/cli/features_test.go @@ -57,22 +57,10 @@ func TestFeaturesList(t *testing.T) { var entitlements codersdk.Entitlements err := json.Unmarshal(buf.Bytes(), &entitlements) require.NoError(t, err, "unmarshal JSON output") - assert.Len(t, entitlements.Features, 7) assert.Empty(t, entitlements.Warnings) - assert.Equal(t, codersdk.EntitlementNotEntitled, - entitlements.Features[codersdk.FeatureUserLimit].Entitlement) - assert.Equal(t, codersdk.EntitlementNotEntitled, - entitlements.Features[codersdk.FeatureAuditLog].Entitlement) - assert.Equal(t, codersdk.EntitlementNotEntitled, - entitlements.Features[codersdk.FeatureBrowserOnly].Entitlement) - assert.Equal(t, codersdk.EntitlementNotEntitled, - entitlements.Features[codersdk.FeatureWorkspaceQuota].Entitlement) - assert.Equal(t, codersdk.EntitlementNotEntitled, - entitlements.Features[codersdk.FeatureTemplateRBAC].Entitlement) - assert.Equal(t, codersdk.EntitlementNotEntitled, - entitlements.Features[codersdk.FeatureSCIM].Entitlement) - assert.Equal(t, codersdk.EntitlementNotEntitled, - entitlements.Features[codersdk.FeatureHighAvailability].Entitlement) + for _, featureName := range codersdk.FeatureNames { + assert.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[featureName].Entitlement) + } assert.False(t, entitlements.HasLicense) assert.False(t, entitlements.Experimental) }) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index b9d095883824e..b65dd4271518a 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -211,12 +211,13 @@ func (api *API) updateEntitlements(ctx context.Context) error { api.entitlementsMu.Lock() defer api.entitlementsMu.Unlock() - entitlements, err := license.Entitlements(ctx, api.Database, api.Logger, len(api.replicaManager.All()), api.Keys, map[string]bool{ + entitlements, err := license.Entitlements(ctx, api.Database, api.Logger, len(api.replicaManager.All()), len(api.GitAuthConfigs), api.Keys, map[string]bool{ codersdk.FeatureAuditLog: api.AuditLogging, codersdk.FeatureBrowserOnly: api.BrowserOnly, codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0, codersdk.FeatureWorkspaceQuota: api.UserWorkspaceQuota != 0, codersdk.FeatureHighAvailability: api.DERPServerRelayAddress != "", + codersdk.FeatureMultipleGitAuth: len(api.GitAuthConfigs) > 1, codersdk.FeatureTemplateRBAC: api.RBAC, }) if err != nil { diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index a8595b5bc6ede..341cb87dff1ec 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -113,6 +113,7 @@ type LicenseOptions struct { WorkspaceQuota bool TemplateRBAC bool HighAvailability bool + MultipleGitAuth bool } // AddLicense generates a new license with the options provided and inserts it. @@ -158,6 +159,11 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { rbacEnabled = 1 } + multipleGitAuth := int64(0) + if options.MultipleGitAuth { + multipleGitAuth = 1 + } + c := &license.Claims{ RegisteredClaims: jwt.RegisteredClaims{ Issuer: "test@testing.test", @@ -179,6 +185,7 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { WorkspaceQuota: workspaceQuota, HighAvailability: highAvailability, TemplateRBAC: rbacEnabled, + MultipleGitAuth: multipleGitAuth, }, } tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index c5bb689db65a9..80f502f527f2d 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -22,6 +22,7 @@ func Entitlements( db database.Store, logger slog.Logger, replicaCount int, + gitAuthCount int, keys map[string]ed25519.PublicKey, enablements map[string]bool, ) (codersdk.Entitlements, error) { @@ -116,6 +117,12 @@ func Entitlements( Enabled: enablements[codersdk.FeatureTemplateRBAC], } } + if claims.Features.MultipleGitAuth > 0 { + entitlements.Features[codersdk.FeatureMultipleGitAuth] = codersdk.Feature{ + Entitlement: entitlement, + Enabled: true, + } + } if claims.AllFeatures { allFeatures = true } @@ -150,6 +157,10 @@ func Entitlements( if featureName == codersdk.FeatureHighAvailability { continue } + // Multiple Git auth has it's own warnings based on the number configured! + if featureName == codersdk.FeatureMultipleGitAuth { + continue + } feature := entitlements.Features[featureName] if !feature.Enabled { continue @@ -173,10 +184,10 @@ func Entitlements( switch feature.Entitlement { case codersdk.EntitlementNotEntitled: if entitlements.HasLicense { - entitlements.Errors = append(entitlements.Warnings, + entitlements.Errors = append(entitlements.Errors, "You have multiple replicas but your license is not entitled to high availability. You will be unable to connect to workspaces.") } else { - entitlements.Errors = append(entitlements.Warnings, + entitlements.Errors = append(entitlements.Errors, "You have multiple replicas but high availability is an Enterprise feature. You will be unable to connect to workspaces.") } case codersdk.EntitlementGracePeriod: @@ -185,6 +196,27 @@ func Entitlements( } } + if gitAuthCount > 1 { + feature := entitlements.Features[codersdk.FeatureMultipleGitAuth] + + switch feature.Entitlement { + case codersdk.EntitlementNotEntitled: + if entitlements.HasLicense { + entitlements.Errors = append(entitlements.Errors, + "You have multiple Git authorizations configured but your license is limited at one.", + ) + } else { + entitlements.Errors = append(entitlements.Errors, + "You have multiple Git authorizations configured but this is an Enterprise feature. Reduce to one.", + ) + } + case codersdk.EntitlementGracePeriod: + entitlements.Warnings = append(entitlements.Warnings, + "You have multiple Git authorizations configured but your license is expired. Reduce to one.", + ) + } + } + for _, featureName := range codersdk.FeatureNames { feature := entitlements.Features[featureName] if feature.Entitlement == codersdk.EntitlementNotEntitled { @@ -219,6 +251,7 @@ type Features struct { WorkspaceQuota int64 `json:"workspace_quota"` TemplateRBAC int64 `json:"template_rbac"` HighAvailability int64 `json:"high_availability"` + MultipleGitAuth int64 `json:"multiple_git_auth"` } type Claims struct { diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index a47dd83c98ab1..75b9e23c59a62 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -31,7 +31,7 @@ func TestEntitlements(t *testing.T) { 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 +47,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) @@ -71,7 +71,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.False(t, entitlements.Trial) @@ -96,7 +96,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 +107,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 +122,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 +133,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 +158,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 +180,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 +203,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 +218,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 +234,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 +250,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 +270,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 +278,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]) + }) } From b6bfdf164a6f6ac5a666c91040fbb3341d5e11bb Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 24 Oct 2022 20:06:52 +0000 Subject: [PATCH 15/17] Fix update loop for oauth token --- coderd/workspaceagents.go | 45 ++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 8ab7da8e450d3..5d3c56f2c8140 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -1012,30 +1012,35 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) return } defer cancelFunc() - select { - case <-r.Context().Done(): - return - case <-authChan: - } - - gitAuthLink, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ - ProviderID: gitAuthConfig.ID, - UserID: workspace.OwnerID, - }) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Failed to get git auth link.", - Detail: err.Error(), + ticker := time.NewTicker(time.Second) + 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.StatusBadRequest, 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 } - - 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. - url, err := api.AccessURL.Parse(fmt.Sprintf("/gitauth/%s", gitAuthConfig.ID)) + redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/gitauth/%s", gitAuthConfig.ID)) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Failed to parse access URL.", @@ -1058,7 +1063,7 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) } httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentGitAuthResponse{ - URL: url.String(), + URL: redirectURL.String(), }) return } @@ -1070,7 +1075,7 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) }).Token() if err != nil { httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentGitAuthResponse{ - URL: url.String(), + URL: redirectURL.String(), }) return } From b12765ce4a05e58a173c2640fa9a876a8626a9a3 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 24 Oct 2022 23:26:16 +0000 Subject: [PATCH 16/17] Jon comments --- cli/gitaskpass.go | 9 ++------- coderd/gitauth/askpass.go | 2 +- coderd/gitauth/config.go | 4 ++-- coderd/httpapi/httpapi.go | 4 ++-- coderd/httpapi/username.go | 14 +++++++------- coderd/httpapi/username_test.go | 8 ++++---- coderd/userauth.go | 4 ++-- coderd/workspaceagents.go | 17 +++++++++-------- 8 files changed, 29 insertions(+), 33 deletions(-) diff --git a/cli/gitaskpass.go b/cli/gitaskpass.go index 088bfa41d7ab4..52af8b31da8a9 100644 --- a/cli/gitaskpass.go +++ b/cli/gitaskpass.go @@ -23,16 +23,11 @@ func gitAskpass() *cobra.Command { Use: "gitaskpass", Hidden: true, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) (err error) { + RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() ctx, stop := signal.NotifyContext(ctx, interruptSignals...) defer stop() - defer func() { - if ctx.Err() != nil { - err = ctx.Err() - } - }() user, host, err := gitauth.ParseAskpass(args[0]) if err != nil { @@ -57,7 +52,7 @@ func gitAskpass() *cobra.Command { } if token.URL != "" { if err := openURL(cmd, token.URL); err != nil { - cmd.Printf("Your browser has been opened to visit:\n\n\t%s\n\n", token.URL) + 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) } diff --git a/coderd/gitauth/askpass.go b/coderd/gitauth/askpass.go index 6420a4898c2c8..e78a5bf20661f 100644 --- a/coderd/gitauth/askpass.go +++ b/coderd/gitauth/askpass.go @@ -32,7 +32,7 @@ func CheckCommand(args, env []string) bool { // 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.Split(prompt, " ") + parts := strings.Fields(prompt) if len(parts) < 3 { return "", "", xerrors.Errorf("askpass prompt must contain 3 words; got %d: %q", len(parts), prompt) } diff --git a/coderd/gitauth/config.go b/coderd/gitauth/config.go index a4aed49343462..cca2842b56e75 100644 --- a/coderd/gitauth/config.go +++ b/coderd/gitauth/config.go @@ -47,8 +47,8 @@ func ConvertConfig(entries []codersdk.DeploymentConfigGitAuth, accessURL *url.UR // Default to the type. entry.ID = string(typ) } - if valid, err := httpapi.UsernameValid(entry.ID); !valid { - return nil, xerrors.Errorf("git auth provider %q doesn't have a valid id: %w", entry.ID, err) + 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] diff --git a/coderd/httpapi/httpapi.go b/coderd/httpapi/httpapi.go index ea144569f5e19..a42b79eaa4db8 100644 --- a/coderd/httpapi/httpapi.go +++ b/coderd/httpapi/httpapi.go @@ -40,8 +40,8 @@ func init() { if !ok { return false } - valid, _ := UsernameValid(str) - return valid + valid := UsernameValid(str) + return valid == nil } for _, tag := range []string{"username", "template_name", "workspace_name"} { err := validate.RegisterValidation(tag, nameValidator) diff --git a/coderd/httpapi/username.go b/coderd/httpapi/username.go index b20b55ce7e3e4..89a9cce92016a 100644 --- a/coderd/httpapi/username.go +++ b/coderd/httpapi/username.go @@ -14,18 +14,18 @@ var ( ) // UsernameValid returns whether the input string is a valid username. -func UsernameValid(str string) (bool, error) { +func UsernameValid(str string) error { if len(str) > 32 { - return false, xerrors.New("must be <= 32 characters") + return xerrors.New("must be <= 32 characters") } if len(str) < 1 { - return false, xerrors.New("must be >= 1 character") + return xerrors.New("must be >= 1 character") } matched := UsernameValidRegex.MatchString(str) if !matched { - return false, xerrors.New("must be alphanumeric with hyphens") + return xerrors.New("must be alphanumeric with hyphens") } - return true, nil + return nil } // UsernameFrom returns a best-effort username from the provided string. @@ -35,7 +35,7 @@ func UsernameValid(str string) (bool, error) { // 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 valid, _ := UsernameValid(str); valid { + if valid := UsernameValid(str); valid == nil { return str } emailAt := strings.LastIndex(str, "@") @@ -43,7 +43,7 @@ func UsernameFrom(str string) string { str = str[:emailAt] } str = usernameReplace.ReplaceAllString(str, "") - if valid, _ := UsernameValid(str); valid { + if valid := UsernameValid(str); valid == nil { return str } return strings.ReplaceAll(namesgenerator.GetRandomName(1), "_", "-") diff --git a/coderd/httpapi/username_test.go b/coderd/httpapi/username_test.go index 57fab3f0b70fe..547d5177c4e56 100644 --- a/coderd/httpapi/username_test.go +++ b/coderd/httpapi/username_test.go @@ -59,8 +59,8 @@ func TestValid(t *testing.T) { testCase := testCase t.Run(testCase.Username, func(t *testing.T) { t.Parallel() - valid, _ := httpapi.UsernameValid(testCase.Username) - require.Equal(t, testCase.Valid, valid) + valid := httpapi.UsernameValid(testCase.Username) + require.Equal(t, testCase.Valid, valid == nil) }) } } @@ -92,8 +92,8 @@ func TestFrom(t *testing.T) { t.Parallel() converted := httpapi.UsernameFrom(testCase.From) t.Log(converted) - valid, _ := httpapi.UsernameValid(converted) - require.True(t, valid) + valid := httpapi.UsernameValid(converted) + require.True(t, valid == nil) if testCase.Match == "" { require.NotEqual(t, testCase.From, converted) } else { diff --git a/coderd/userauth.go b/coderd/userauth.go index d4624ca768315..b1392d0569b83 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -261,8 +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. - usernameValid, _ := httpapi.UsernameValid(username) - if !usernameValid { + usernameValid := httpapi.UsernameValid(username) + if usernameValid != nil { // If no username is provided, we can default to use the email address. // This will be converted in the from function below, so it's safe // to keep the domain. diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 5d3c56f2c8140..edae77f37777e 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -936,7 +936,7 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) gitURL := r.URL.Query().Get("url") if gitURL == "" { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Missing url query parameter!", + Message: "Missing 'url' query parameter!", }) return } @@ -962,7 +962,7 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) // 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.StatusBadRequest, codersdk.Response{ + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to get workspace resource.", Detail: err.Error(), }) @@ -970,7 +970,7 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) } build, err := api.Database.GetWorkspaceBuildByJobID(ctx, resource.JobID) if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to get build.", Detail: err.Error(), }) @@ -978,7 +978,7 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) } workspace, err := api.Database.GetWorkspaceByID(ctx, build.WorkspaceID) if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to get workspace.", Detail: err.Error(), }) @@ -1013,6 +1013,7 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) } defer cancelFunc() ticker := time.NewTicker(time.Second) + defer ticker.Stop() for { select { case <-r.Context().Done(): @@ -1025,7 +1026,7 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) UserID: workspace.OwnerID, }) if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to get git auth link.", Detail: err.Error(), }) @@ -1042,7 +1043,7 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) // 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.StatusBadRequest, codersdk.Response{ + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to parse access URL.", Detail: err.Error(), }) @@ -1055,7 +1056,7 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) }) if err != nil { if !errors.Is(err, sql.ErrNoRows) { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to get git auth link.", Detail: err.Error(), }) @@ -1091,7 +1092,7 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) OAuthExpiry: token.Expiry, }) if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to update git auth link.", Detail: err.Error(), }) From 29c9e472990e7d1d3ef38984c3ce302f9f98b74e Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 24 Oct 2022 23:26:56 +0000 Subject: [PATCH 17/17] Add deployment config page --- cli/deployment/config.go | 6 +- cli/gitaskpass_test.go | 12 +- coderd/coderdtest/authorize.go | 1 + codersdk/workspaceagents.go | 9 +- enterprise/coderd/license/license_test.go | 2 + enterprise/coderd/licenses_test.go | 2 + site/src/AppRouter.tsx | 29 +++-- site/src/components/AlertBanner/alertTypes.ts | 2 +- .../AlertBanner/severityConstants.tsx | 7 ++ .../DeploySettingsLayout/Sidebar.tsx | 27 ++++- site/src/components/Icons/GitIcon.tsx | 7 ++ .../GitAuthSettingsPage.tsx | 109 ++++++++++++++++++ ...tingsPage.tsx => UserAuthSettingsPage.tsx} | 8 +- site/static/gitauth.mp4 | Bin 0 -> 200488 bytes 14 files changed, 196 insertions(+), 25 deletions(-) create mode 100644 site/src/components/Icons/GitIcon.tsx create mode 100644 site/src/pages/DeploySettingsPage/GitAuthSettingsPage.tsx rename site/src/pages/DeploySettingsPage/{AuthSettingsPage.tsx => UserAuthSettingsPage.tsx} (92%) create mode 100644 site/static/gitauth.mp4 diff --git a/cli/deployment/config.go b/cli/deployment/config.go index c64b596026f2f..781869906f4a3 100644 --- a/cli/deployment/config.go +++ b/cli/deployment/config.go @@ -470,7 +470,11 @@ func readSliceFromViper[T any](vip *viper.Viper, key string, value any) []T { if instance == nil { break } - returnValues = append(returnValues, instance.Interface().(T)) + value, ok := instance.Interface().(T) + if !ok { + continue + } + returnValues = append(returnValues, value) } return returnValues } diff --git a/cli/gitaskpass_test.go b/cli/gitaskpass_test.go index 2541ae5918474..12142ac8499d0 100644 --- a/cli/gitaskpass_test.go +++ b/cli/gitaskpass_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "sync/atomic" "testing" "github.com/stretchr/testify/assert" @@ -60,14 +61,15 @@ func TestGitAskpass(t *testing.T) { }) t.Run("Poll", func(t *testing.T) { - resp := codersdk.WorkspaceAgentGitAuthResponse{ + 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) { if r.URL.Query().Has("listen") { poll <- struct{}{} - if resp.URL != "" { + if resp.Load().URL != "" { httpapi.Write(context.Background(), w, http.StatusInternalServerError, resp) return } @@ -85,10 +87,10 @@ func TestGitAskpass(t *testing.T) { assert.NoError(t, err) }() <-poll - resp = codersdk.WorkspaceAgentGitAuthResponse{ + resp.Store(&codersdk.WorkspaceAgentGitAuthResponse{ Username: "username", Password: "password", - } + }) pty.ExpectMatch("username") }) } diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index ce28f28899211..f209e7e3b4999 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -57,6 +57,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { "POST:/api/v2/workspaceagents/aws-instance-identity": {NoAuthorize: true}, "POST:/api/v2/workspaceagents/azure-instance-identity": {NoAuthorize: true}, "POST:/api/v2/workspaceagents/google-instance-identity": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/me/gitauth": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/me/gitsshkey": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/me/metadata": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/me/coordinate": {NoAuthorize: true}, diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 8b952cd9684a7..b3c82772c2917 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -655,13 +655,14 @@ type WorkspaceAgentGitAuthResponse struct { } // WorkspaceAgentGitAuth submits a URL to fetch a GIT_ASKPASS username -// and password for. If the URL doesn't match +// and password for. +// nolint:revive func (c *Client) WorkspaceAgentGitAuth(ctx context.Context, gitURL string, listen bool) (WorkspaceAgentGitAuthResponse, error) { - url := "/api/v2/workspaceagents/me/gitauth?url=" + url.QueryEscape(gitURL) + reqURL := "/api/v2/workspaceagents/me/gitauth?url=" + url.QueryEscape(gitURL) if listen { - url += "&listen" + reqURL += "&listen" } - res, err := c.Request(ctx, http.MethodGet, url, nil) + res, err := c.Request(ctx, http.MethodGet, reqURL, nil) if err != nil { return WorkspaceAgentGitAuthResponse{}, xerrors.Errorf("execute request: %w", err) } diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 75b9e23c59a62..a1b26f8dab9e4 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -26,6 +26,7 @@ func TestEntitlements(t *testing.T) { codersdk.FeatureWorkspaceQuota: true, codersdk.FeatureHighAvailability: true, codersdk.FeatureTemplateRBAC: true, + codersdk.FeatureMultipleGitAuth: true, } t.Run("Defaults", func(t *testing.T) { @@ -68,6 +69,7 @@ func TestEntitlements(t *testing.T) { WorkspaceQuota: true, HighAvailability: true, TemplateRBAC: true, + MultipleGitAuth: true, }), Exp: time.Now().Add(time.Hour), }) diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go index aa4dddf1fd5f1..b4254bfccf4f5 100644 --- a/enterprise/coderd/licenses_test.go +++ b/enterprise/coderd/licenses_test.go @@ -108,6 +108,7 @@ func TestGetLicense(t *testing.T) { codersdk.FeatureWorkspaceQuota: json.Number("0"), codersdk.FeatureHighAvailability: json.Number("0"), codersdk.FeatureTemplateRBAC: json.Number("1"), + codersdk.FeatureMultipleGitAuth: json.Number("0"), }, licenses[0].Claims["features"]) assert.Equal(t, int32(2), licenses[1].ID) assert.Equal(t, "testing2", licenses[1].Claims["account_id"]) @@ -120,6 +121,7 @@ func TestGetLicense(t *testing.T) { codersdk.FeatureWorkspaceQuota: json.Number("0"), codersdk.FeatureHighAvailability: json.Number("0"), codersdk.FeatureTemplateRBAC: json.Number("0"), + codersdk.FeatureMultipleGitAuth: json.Number("0"), }, licenses[1].Claims["features"]) }) } diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 6106de6fa4d7c..ca9191030c545 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -74,15 +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") -) +const GitAuthPage = lazy(() => import("./pages/GitAuthPage/GitAuthPage")) export const AppRouter: FC = () => { const xServices = useContext(XServiceContext) @@ -305,14 +306,28 @@ export const AppRouter: FC = () => { } /> + + + + + + + } + /> + - + diff --git a/site/src/components/AlertBanner/alertTypes.ts b/site/src/components/AlertBanner/alertTypes.ts index 154c2c9a2cdab..638f73748d441 100644 --- a/site/src/components/AlertBanner/alertTypes.ts +++ b/site/src/components/AlertBanner/alertTypes.ts @@ -1,7 +1,7 @@ import { ApiError } from "api/errors" import { ReactElement } from "react" -export type Severity = "warning" | "error" +export type Severity = "warning" | "error" | "info" export interface AlertBannerProps { severity: Severity diff --git a/site/src/components/AlertBanner/severityConstants.tsx b/site/src/components/AlertBanner/severityConstants.tsx index 0c759fb83921c..fcc5e3cc0c89f 100644 --- a/site/src/components/AlertBanner/severityConstants.tsx +++ b/site/src/components/AlertBanner/severityConstants.tsx @@ -1,5 +1,6 @@ import ReportProblemOutlinedIcon from "@material-ui/icons/ReportProblemOutlined" import ErrorOutlineOutlinedIcon from "@material-ui/icons/ErrorOutlineOutlined" +import InfoOutlinedIcon from "@material-ui/icons/InfoOutlined" import { colors } from "theme/colors" import { Severity } from "./alertTypes" import { ReactElement } from "react" @@ -26,4 +27,10 @@ export const severityConstants: Record< /> ), }, + info: { + color: colors.blue[7], + icon: ( + + ), + }, } diff --git a/site/src/components/DeploySettingsLayout/Sidebar.tsx b/site/src/components/DeploySettingsLayout/Sidebar.tsx index ad195acbdcd25..42d7ef45d3ac6 100644 --- a/site/src/components/DeploySettingsLayout/Sidebar.tsx +++ b/site/src/components/DeploySettingsLayout/Sidebar.tsx @@ -3,10 +3,18 @@ import LaunchOutlined from "@material-ui/icons/LaunchOutlined" import LockRounded from "@material-ui/icons/LockRounded" import Globe from "@material-ui/icons/Public" import VpnKeyOutlined from "@material-ui/icons/VpnKeyOutlined" +import { useSelector } from "@xstate/react" +import { GitIcon } from "components/Icons/GitIcon" import { Stack } from "components/Stack/Stack" -import React, { ElementType, PropsWithChildren, ReactNode } from "react" +import React, { + ElementType, + PropsWithChildren, + ReactNode, + useContext, +} from "react" import { NavLink } from "react-router-dom" import { combineClasses } from "util/combineClasses" +import { XServiceContext } from "../../xServices/StateContext" const SidebarNavItem: React.FC< PropsWithChildren<{ href: string; icon: ReactNode }> @@ -39,6 +47,11 @@ const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({ export const Sidebar: React.FC = () => { const styles = useStyles() + const xServices = useContext(XServiceContext) + const experimental = useSelector( + xServices.entitlementsXService, + (state) => state.context.entitlements.experimental, + ) return (