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

* Add scaffolding

* Move migration

* Add endpoints for gitauth

* Add configuration files and tests!

* Update typesgen

* Convert configuration format for git auth

* Fix unclosed database conn

* Add overriding VS Code configuration

* Fix Git screen

* Write VS Code special configuration if providers exist

* Enable automatic cloning from VS Code

* Add tests for gitaskpass

* Fix feature visibiliy

* Add banner for too many configurations

* Fix update loop for oauth token

* Jon comments

* Add deployment config page
This commit is contained in:
Kyle Carberry
2022-10-24 19:46:24 -05:00
committed by GitHub
parent 585045b359
commit eec406b739
62 changed files with 2211 additions and 88 deletions

View File

@ -3,6 +3,7 @@ package coderd
import (
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"net/http"
"net/url"
@ -30,6 +31,7 @@ import (
"github.com/coder/coder/coderd/audit"
"github.com/coder/coder/coderd/awsidentity"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/gitauth"
"github.com/coder/coder/coderd/gitsshkey"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
@ -82,6 +84,7 @@ type Options struct {
Telemetry telemetry.Reporter
TracerProvider trace.TracerProvider
AutoImportTemplates []AutoImportTemplate
GitAuthConfigs []*gitauth.Config
RealIPConfig *httpmw.RealIPConfig
// TLSCertificates is used to mesh DERP servers securely.
@ -262,6 +265,17 @@ func New(options *Options) *API {
})
})
r.Route("/gitauth", func(r chi.Router) {
for _, gitAuthConfig := range options.GitAuthConfigs {
r.Route(fmt.Sprintf("/%s", gitAuthConfig.ID), func(r chi.Router) {
r.Use(
httpmw.ExtractOAuth2(gitAuthConfig),
apiKeyMiddleware,
)
r.Get("/callback", api.gitAuthCallback(gitAuthConfig))
})
}
})
r.Route("/api/v2", func(r chi.Router) {
api.APIHandler = r
@ -474,6 +488,7 @@ func New(options *Options) *API {
r.Get("/metadata", api.workspaceAgentMetadata)
r.Post("/version", api.postWorkspaceAgentVersion)
r.Post("/app-health", api.postWorkspaceAppHealth)
r.Get("/gitauth", api.workspaceAgentsGitAuth)
r.Get("/gitsshkey", api.agentGitSSHKey)
r.Get("/coordinate", api.workspaceAgentCoordinate)
r.Get("/report-stats", api.workspaceAgentReportStats)

View File

@ -57,6 +57,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
"POST:/api/v2/workspaceagents/aws-instance-identity": {NoAuthorize: true},
"POST:/api/v2/workspaceagents/azure-instance-identity": {NoAuthorize: true},
"POST:/api/v2/workspaceagents/google-instance-identity": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/me/gitauth": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/me/gitsshkey": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/me/metadata": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/me/coordinate": {NoAuthorize: true},

View File

@ -55,6 +55,7 @@ import (
"github.com/coder/coder/coderd/awsidentity"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/dbtestutil"
"github.com/coder/coder/coderd/gitauth"
"github.com/coder/coder/coderd/gitsshkey"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
@ -88,6 +89,7 @@ type Options struct {
AutobuildStats chan<- executor.Stats
Auditor audit.Auditor
TLSCertificates []tls.Certificate
GitAuthConfigs []*gitauth.Config
// IncludeProvisionerDaemon when true means to start an in-memory provisionerD
IncludeProvisionerDaemon bool
@ -235,6 +237,7 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can
Database: options.Database,
Pubsub: options.Pubsub,
Experimental: options.Experimental,
GitAuthConfigs: options.GitAuthConfigs,
Auditor: options.Auditor,
AWSCertificates: options.AWSCertificates,

View File

@ -34,6 +34,7 @@ func New() database.Store {
organizationMembers: make([]database.OrganizationMember, 0),
organizations: make([]database.Organization, 0),
users: make([]database.User, 0),
gitAuthLinks: make([]database.GitAuthLink, 0),
groups: make([]database.Group, 0),
groupMembers: make([]database.GroupMember, 0),
auditLogs: make([]database.AuditLog, 0),
@ -90,6 +91,7 @@ type data struct {
agentStats []database.AgentStat
auditLogs []database.AuditLog
files []database.File
gitAuthLinks []database.GitAuthLink
gitSSHKey []database.GitSSHKey
groups []database.Group
groupMembers []database.GroupMember
@ -3438,3 +3440,54 @@ func (q *fakeQuerier) GetReplicasUpdatedAfter(_ context.Context, updatedAt time.
}
return replicas, nil
}
func (q *fakeQuerier) GetGitAuthLink(_ context.Context, arg database.GetGitAuthLinkParams) (database.GitAuthLink, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, gitAuthLink := range q.gitAuthLinks {
if arg.UserID != gitAuthLink.UserID {
continue
}
if arg.ProviderID != gitAuthLink.ProviderID {
continue
}
return gitAuthLink, nil
}
return database.GitAuthLink{}, sql.ErrNoRows
}
func (q *fakeQuerier) InsertGitAuthLink(_ context.Context, arg database.InsertGitAuthLinkParams) (database.GitAuthLink, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
// nolint:gosimple
gitAuthLink := database.GitAuthLink{
ProviderID: arg.ProviderID,
UserID: arg.UserID,
CreatedAt: arg.CreatedAt,
UpdatedAt: arg.UpdatedAt,
OAuthAccessToken: arg.OAuthAccessToken,
OAuthRefreshToken: arg.OAuthRefreshToken,
OAuthExpiry: arg.OAuthExpiry,
}
q.gitAuthLinks = append(q.gitAuthLinks, gitAuthLink)
return gitAuthLink, nil
}
func (q *fakeQuerier) UpdateGitAuthLink(_ context.Context, arg database.UpdateGitAuthLinkParams) error {
q.mutex.Lock()
defer q.mutex.Unlock()
for index, gitAuthLink := range q.gitAuthLinks {
if gitAuthLink.ProviderID != arg.ProviderID {
continue
}
if gitAuthLink.UserID != arg.UserID {
continue
}
gitAuthLink.UpdatedAt = arg.UpdatedAt
gitAuthLink.OAuthAccessToken = arg.OAuthAccessToken
gitAuthLink.OAuthRefreshToken = arg.OAuthRefreshToken
gitAuthLink.OAuthExpiry = arg.OAuthExpiry
q.gitAuthLinks[index] = gitAuthLink
}
return nil
}

View File

@ -162,6 +162,16 @@ CREATE TABLE files (
id uuid DEFAULT gen_random_uuid() NOT NULL
);
CREATE TABLE git_auth_links (
provider_id text NOT NULL,
user_id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
oauth_access_token text NOT NULL,
oauth_refresh_token text NOT NULL,
oauth_expiry timestamp with time zone NOT NULL
);
CREATE TABLE gitsshkeys (
user_id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
@ -462,6 +472,9 @@ ALTER TABLE ONLY files
ALTER TABLE ONLY files
ADD CONSTRAINT files_pkey PRIMARY KEY (id);
ALTER TABLE ONLY git_auth_links
ADD CONSTRAINT git_auth_links_provider_id_user_id_key UNIQUE (provider_id, user_id);
ALTER TABLE ONLY gitsshkeys
ADD CONSTRAINT gitsshkeys_pkey PRIMARY KEY (user_id);

View File

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

View File

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS git_auth_links (
provider_id text NOT NULL,
user_id uuid NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
oauth_access_token text NOT NULL,
oauth_refresh_token text NOT NULL,
oauth_expiry timestamptz NOT NULL,
UNIQUE(provider_id, user_id)
);

View File

@ -428,6 +428,16 @@ type File struct {
ID uuid.UUID `db:"id" json:"id"`
}
type GitAuthLink struct {
ProviderID string `db:"provider_id" json:"provider_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"`
OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"`
OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"`
}
type GitSSHKey struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`

View File

@ -44,6 +44,7 @@ type sqlcQuerier interface {
GetDeploymentID(ctx context.Context) (string, error)
GetFileByHashAndCreator(ctx context.Context, arg GetFileByHashAndCreatorParams) (File, error)
GetFileByID(ctx context.Context, id uuid.UUID) (File, error)
GetGitAuthLink(ctx context.Context, arg GetGitAuthLinkParams) (GitAuthLink, error)
GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error)
GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error)
GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error)
@ -132,6 +133,7 @@ type sqlcQuerier interface {
InsertDERPMeshKey(ctx context.Context, value string) error
InsertDeploymentID(ctx context.Context, value string) error
InsertFile(ctx context.Context, arg InsertFileParams) (File, error)
InsertGitAuthLink(ctx context.Context, arg InsertGitAuthLinkParams) (GitAuthLink, error)
InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error)
InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error)
InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error
@ -157,6 +159,7 @@ type sqlcQuerier interface {
ParameterValue(ctx context.Context, id uuid.UUID) (ParameterValue, error)
ParameterValues(ctx context.Context, arg ParameterValuesParams) ([]ParameterValue, error)
UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error
UpdateGitAuthLink(ctx context.Context, arg UpdateGitAuthLinkParams) error
UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error)
UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error)
UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error)

View File

@ -753,6 +753,113 @@ func (q *sqlQuerier) InsertFile(ctx context.Context, arg InsertFileParams) (File
return i, err
}
const getGitAuthLink = `-- name: GetGitAuthLink :one
SELECT provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry FROM git_auth_links WHERE provider_id = $1 AND user_id = $2
`
type GetGitAuthLinkParams struct {
ProviderID string `db:"provider_id" json:"provider_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
}
func (q *sqlQuerier) GetGitAuthLink(ctx context.Context, arg GetGitAuthLinkParams) (GitAuthLink, error) {
row := q.db.QueryRowContext(ctx, getGitAuthLink, arg.ProviderID, arg.UserID)
var i GitAuthLink
err := row.Scan(
&i.ProviderID,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
&i.OAuthAccessToken,
&i.OAuthRefreshToken,
&i.OAuthExpiry,
)
return i, err
}
const insertGitAuthLink = `-- name: InsertGitAuthLink :one
INSERT INTO git_auth_links (
provider_id,
user_id,
created_at,
updated_at,
oauth_access_token,
oauth_refresh_token,
oauth_expiry
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7
) RETURNING provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry
`
type InsertGitAuthLinkParams struct {
ProviderID string `db:"provider_id" json:"provider_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"`
OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"`
OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"`
}
func (q *sqlQuerier) InsertGitAuthLink(ctx context.Context, arg InsertGitAuthLinkParams) (GitAuthLink, error) {
row := q.db.QueryRowContext(ctx, insertGitAuthLink,
arg.ProviderID,
arg.UserID,
arg.CreatedAt,
arg.UpdatedAt,
arg.OAuthAccessToken,
arg.OAuthRefreshToken,
arg.OAuthExpiry,
)
var i GitAuthLink
err := row.Scan(
&i.ProviderID,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
&i.OAuthAccessToken,
&i.OAuthRefreshToken,
&i.OAuthExpiry,
)
return i, err
}
const updateGitAuthLink = `-- name: UpdateGitAuthLink :exec
UPDATE git_auth_links SET
updated_at = $3,
oauth_access_token = $4,
oauth_refresh_token = $5,
oauth_expiry = $6
WHERE provider_id = $1 AND user_id = $2
`
type UpdateGitAuthLinkParams struct {
ProviderID string `db:"provider_id" json:"provider_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"`
OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"`
OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"`
}
func (q *sqlQuerier) UpdateGitAuthLink(ctx context.Context, arg UpdateGitAuthLinkParams) error {
_, err := q.db.ExecContext(ctx, updateGitAuthLink,
arg.ProviderID,
arg.UserID,
arg.UpdatedAt,
arg.OAuthAccessToken,
arg.OAuthRefreshToken,
arg.OAuthExpiry,
)
return err
}
const deleteGitSSHKey = `-- name: DeleteGitSSHKey :exec
DELETE FROM
gitsshkeys

View File

@ -0,0 +1,29 @@
-- name: GetGitAuthLink :one
SELECT * FROM git_auth_links WHERE provider_id = $1 AND user_id = $2;
-- name: InsertGitAuthLink :one
INSERT INTO git_auth_links (
provider_id,
user_id,
created_at,
updated_at,
oauth_access_token,
oauth_refresh_token,
oauth_expiry
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7
) RETURNING *;
-- name: UpdateGitAuthLink :exec
UPDATE git_auth_links SET
updated_at = $3,
oauth_access_token = $4,
oauth_refresh_token = $5,
oauth_expiry = $6
WHERE provider_id = $1 AND user_id = $2;

View File

@ -7,6 +7,7 @@ type UniqueConstraint string
// UniqueConstraint enums.
const (
UniqueFilesHashCreatedByKey UniqueConstraint = "files_hash_created_by_key" // ALTER TABLE ONLY files ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by);
UniqueGitAuthLinksProviderIDUserIDKey UniqueConstraint = "git_auth_links_provider_id_user_id_key" // ALTER TABLE ONLY git_auth_links ADD CONSTRAINT git_auth_links_provider_id_user_id_key UNIQUE (provider_id, user_id);
UniqueGroupMembersUserIDGroupIDKey UniqueConstraint = "group_members_user_id_group_id_key" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_group_id_key UNIQUE (user_id, group_id);
UniqueGroupsNameOrganizationIDKey UniqueConstraint = "groups_name_organization_id_key" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_name_organization_id_key UNIQUE (name, organization_id);
UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt);

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

@ -0,0 +1,70 @@
package gitauth
import (
"net/url"
"regexp"
"strings"
"golang.org/x/xerrors"
)
// https://github.com/microsoft/vscode/blob/328646ebc2f5016a1c67e0b23a0734bd598ec5a8/extensions/git/src/askpass-main.ts#L46
var hostReplace = regexp.MustCompile(`^["']+|["':]+$`)
// CheckCommand returns true if the command arguments and environment
// match those when the GIT_ASKPASS command is invoked by git.
func CheckCommand(args, env []string) bool {
if len(args) != 1 || (!strings.HasPrefix(args[0], "Username ") && !strings.HasPrefix(args[0], "Password ")) {
return false
}
for _, e := range env {
if strings.HasPrefix(e, "GIT_PREFIX=") {
return true
}
}
return false
}
// ParseAskpass returns the user and host from a git askpass prompt. For
// example: "user1" and "https://github.com". Note that for HTTP
// protocols, the URL will never contain a path.
//
// For details on how the prompt is formatted, see `credential_ask_one`:
// https://github.com/git/git/blob/bbe21b64a08f89475d8a3818e20c111378daa621/credential.c#L173-L191
func ParseAskpass(prompt string) (user string, host string, err error) {
parts := strings.Fields(prompt)
if len(parts) < 3 {
return "", "", xerrors.Errorf("askpass prompt must contain 3 words; got %d: %q", len(parts), prompt)
}
switch parts[0] {
case "Username", "Password":
default:
return "", "", xerrors.Errorf("unknown prompt type: %q", prompt)
}
host = parts[2]
host = hostReplace.ReplaceAllString(host, "")
// Validate the input URL to ensure it's in an expected format.
u, err := url.Parse(host)
if err != nil {
return "", "", xerrors.Errorf("parse host failed: %w", err)
}
switch u.Scheme {
case "http", "https":
default:
return "", "", xerrors.Errorf("unsupported scheme: %q", u.Scheme)
}
if u.Host == "" {
return "", "", xerrors.Errorf("host is empty")
}
user = u.User.Username()
u.User = nil
host = u.String()
return user, host, nil
}

View File

@ -0,0 +1,72 @@
package gitauth_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/gitauth"
)
func TestCheckCommand(t *testing.T) {
t.Parallel()
t.Run("Success", func(t *testing.T) {
t.Parallel()
valid := gitauth.CheckCommand([]string{"Username "}, []string{"GIT_PREFIX=/example"})
require.True(t, valid)
})
t.Run("Failure", func(t *testing.T) {
t.Parallel()
valid := gitauth.CheckCommand([]string{}, []string{})
require.False(t, valid)
})
}
func TestParse(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
in string
wantUser string
wantHost string
}{
{
in: "Username for 'https://github.com': ",
wantUser: "",
wantHost: "https://github.com",
},
{
in: "Username for 'https://enterprise.github.com': ",
wantUser: "",
wantHost: "https://enterprise.github.com",
},
{
in: "Username for 'http://wow.io': ",
wantUser: "",
wantHost: "http://wow.io",
},
{
in: "Password for 'https://myuser@github.com': ",
wantUser: "myuser",
wantHost: "https://github.com",
},
{
in: "Password for 'https://myuser@enterprise.github.com': ",
wantUser: "myuser",
wantHost: "https://enterprise.github.com",
},
{
in: "Password for 'http://myuser@wow.io': ",
wantUser: "myuser",
wantHost: "http://wow.io",
},
} {
tc := tc
t.Run(tc.in, func(t *testing.T) {
t.Parallel()
user, host, err := gitauth.ParseAskpass(tc.in)
require.NoError(t, err)
require.Equal(t, tc.wantUser, user)
require.Equal(t, tc.wantHost, host)
})
}
}

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

@ -0,0 +1,103 @@
package gitauth
import (
"fmt"
"net/url"
"regexp"
"golang.org/x/oauth2"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/codersdk"
)
// Config is used for authentication for Git operations.
type Config struct {
httpmw.OAuth2Config
// ID is a unique identifier for the authenticator.
ID string
// Regex is a regexp that URLs will match against.
Regex *regexp.Regexp
// Type is the type of provider.
Type codersdk.GitProvider
}
// ConvertConfig converts the YAML configuration entry to the
// parsed and ready-to-consume provider type.
func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Config, error) {
ids := map[string]struct{}{}
configs := []*Config{}
for _, entry := range entries {
var typ codersdk.GitProvider
switch entry.Type {
case codersdk.GitProviderAzureDevops:
typ = codersdk.GitProviderAzureDevops
case codersdk.GitProviderBitBucket:
typ = codersdk.GitProviderBitBucket
case codersdk.GitProviderGitHub:
typ = codersdk.GitProviderGitHub
case codersdk.GitProviderGitLab:
typ = codersdk.GitProviderGitLab
default:
return nil, xerrors.Errorf("unknown git provider type: %q", entry.Type)
}
if entry.ID == "" {
// Default to the type.
entry.ID = string(typ)
}
if valid := httpapi.UsernameValid(entry.ID); valid != nil {
return nil, xerrors.Errorf("git auth provider %q doesn't have a valid id: %w", entry.ID, valid)
}
_, exists := ids[entry.ID]
if exists {
if entry.ID == string(typ) {
return nil, xerrors.Errorf("multiple %s git auth providers provided. you must specify a unique id for each", typ)
}
return nil, xerrors.Errorf("multiple git providers exist with the id %q. specify a unique id for each", entry.ID)
}
ids[entry.ID] = struct{}{}
if entry.ClientID == "" {
return nil, xerrors.Errorf("%q git auth provider: client_id must be provided", entry.ID)
}
if entry.ClientSecret == "" {
return nil, xerrors.Errorf("%q git auth provider: client_secret must be provided", entry.ID)
}
authRedirect, err := accessURL.Parse(fmt.Sprintf("/gitauth/%s/callback", entry.ID))
if err != nil {
return nil, xerrors.Errorf("parse gitauth callback url: %w", err)
}
regex := regex[typ]
if entry.Regex != "" {
regex, err = regexp.Compile(entry.Regex)
if err != nil {
return nil, xerrors.Errorf("compile regex for git auth provider %q: %w", entry.ID, entry.Regex)
}
}
oauth2Config := &oauth2.Config{
ClientID: entry.ClientID,
ClientSecret: entry.ClientSecret,
Endpoint: endpoint[typ],
RedirectURL: authRedirect.String(),
Scopes: scope[typ],
}
var oauthConfig httpmw.OAuth2Config = oauth2Config
// Azure DevOps uses JWT token authentication!
if typ == codersdk.GitProviderAzureDevops {
oauthConfig = newJWTOAuthConfig(oauth2Config)
}
configs = append(configs, &Config{
OAuth2Config: oauthConfig,
ID: entry.ID,
Regex: regex,
Type: typ,
})
}
return configs, nil
}

View File

@ -0,0 +1,78 @@
package gitauth_test
import (
"net/url"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/gitauth"
"github.com/coder/coder/codersdk"
)
func TestConvertYAML(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
Name string
Input []codersdk.GitAuthConfig
Output []*gitauth.Config
Error string
}{{
Name: "InvalidType",
Input: []codersdk.GitAuthConfig{{
Type: "moo",
}},
Error: "unknown git provider type",
}, {
Name: "InvalidID",
Input: []codersdk.GitAuthConfig{{
Type: codersdk.GitProviderGitHub,
ID: "$hi$",
}},
Error: "doesn't have a valid id",
}, {
Name: "NoClientID",
Input: []codersdk.GitAuthConfig{{
Type: codersdk.GitProviderGitHub,
}},
Error: "client_id must be provided",
}, {
Name: "NoClientSecret",
Input: []codersdk.GitAuthConfig{{
Type: codersdk.GitProviderGitHub,
ClientID: "example",
}},
Error: "client_secret must be provided",
}, {
Name: "DuplicateType",
Input: []codersdk.GitAuthConfig{{
Type: codersdk.GitProviderGitHub,
ClientID: "example",
ClientSecret: "example",
}, {
Type: codersdk.GitProviderGitHub,
}},
Error: "multiple github git auth providers provided",
}, {
Name: "InvalidRegex",
Input: []codersdk.GitAuthConfig{{
Type: codersdk.GitProviderGitHub,
ClientID: "example",
ClientSecret: "example",
Regex: `\K`,
}},
Error: "compile regex for git auth provider",
}} {
tc := tc
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
output, err := gitauth.ConvertConfig(tc.Input, &url.URL{})
if tc.Error != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.Error)
return
}
require.Equal(t, tc.Output, output)
})
}
}

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

@ -0,0 +1,83 @@
package gitauth
import (
"context"
"net/url"
"regexp"
"golang.org/x/oauth2"
"golang.org/x/oauth2/github"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/codersdk"
)
// endpoint contains default SaaS URLs for each Git provider.
var endpoint = map[codersdk.GitProvider]oauth2.Endpoint{
codersdk.GitProviderAzureDevops: {
AuthURL: "https://app.vssps.visualstudio.com/oauth2/authorize",
TokenURL: "https://app.vssps.visualstudio.com/oauth2/token",
},
codersdk.GitProviderBitBucket: {
AuthURL: "https://bitbucket.org/site/oauth2/authorize",
TokenURL: "https://bitbucket.org/site/oauth2/access_token",
},
codersdk.GitProviderGitLab: {
AuthURL: "https://gitlab.com/oauth/authorize",
TokenURL: "https://gitlab.com/oauth/token",
},
codersdk.GitProviderGitHub: github.Endpoint,
}
// scope contains defaults for each Git provider.
var scope = map[codersdk.GitProvider][]string{
codersdk.GitProviderAzureDevops: {"vso.code_write"},
codersdk.GitProviderBitBucket: {"repository:write"},
codersdk.GitProviderGitLab: {"write_repository"},
codersdk.GitProviderGitHub: {"repo"},
}
// regex provides defaults for each Git provider to
// match their SaaS host URL. This is configurable by each provider.
var regex = map[codersdk.GitProvider]*regexp.Regexp{
codersdk.GitProviderAzureDevops: regexp.MustCompile(`dev\.azure\.com`),
codersdk.GitProviderBitBucket: regexp.MustCompile(`bitbucket\.org`),
codersdk.GitProviderGitLab: regexp.MustCompile(`gitlab\.com`),
codersdk.GitProviderGitHub: regexp.MustCompile(`github\.com`),
}
// newJWTOAuthConfig creates a new OAuth2 config that uses a custom
// assertion method that works with Azure Devops. See:
// https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/oauth?view=azure-devops
func newJWTOAuthConfig(config *oauth2.Config) httpmw.OAuth2Config {
return &jwtConfig{config}
}
type jwtConfig struct {
*oauth2.Config
}
func (c *jwtConfig) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
return c.Config.AuthCodeURL(state, append(opts, oauth2.SetAuthURLParam("response_type", "Assertion"))...)
}
func (c *jwtConfig) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
v := url.Values{
"client_assertion_type": {},
"client_assertion": {c.ClientSecret},
"assertion": {code},
"grant_type": {},
}
if c.RedirectURL != "" {
v.Set("redirect_uri", c.RedirectURL)
}
return c.Config.Exchange(ctx, code,
append(opts,
oauth2.SetAuthURLParam("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
oauth2.SetAuthURLParam("client_assertion", c.ClientSecret),
oauth2.SetAuthURLParam("assertion", code),
oauth2.SetAuthURLParam("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
oauth2.SetAuthURLParam("code", ""),
)...,
)
}

View File

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

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

@ -0,0 +1,81 @@
package gitauth
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"github.com/adrg/xdg"
"github.com/spf13/afero"
"golang.org/x/xerrors"
)
// OverrideVSCodeConfigs overwrites a few properties to consume
// GIT_ASKPASS from the host instead of VS Code-specific authentication.
func OverrideVSCodeConfigs(fs afero.Fs) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
mutate := func(m map[string]interface{}) {
// This prevents VS Code from overriding GIT_ASKPASS, which
// we use to automatically authenticate Git providers.
m["git.useIntegratedAskPass"] = false
// This prevents VS Code from using it's own GitHub authentication
// which would circumvent cloning with Coder-configured providers.
m["github.gitAuthentication"] = false
}
for _, configPath := range []string{
// code-server's default configuration path.
filepath.Join(xdg.DataHome, "code-server", "Machine", "settings.json"),
// vscode-remote's default configuration path.
filepath.Join(home, ".vscode-server", "data", "Machine", "settings.json"),
} {
_, err := fs.Stat(configPath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return xerrors.Errorf("stat %q: %w", configPath, err)
}
m := map[string]interface{}{}
mutate(m)
data, err := json.MarshalIndent(m, "", "\t")
if err != nil {
return xerrors.Errorf("marshal: %w", err)
}
err = fs.MkdirAll(filepath.Dir(configPath), 0o700)
if err != nil {
return xerrors.Errorf("mkdir all: %w", err)
}
err = afero.WriteFile(fs, configPath, data, 0600)
if err != nil {
return xerrors.Errorf("write %q: %w", configPath, err)
}
continue
}
data, err := afero.ReadFile(fs, configPath)
if err != nil {
return xerrors.Errorf("read %q: %w", configPath, err)
}
mapping := map[string]interface{}{}
err = json.Unmarshal(data, &mapping)
if err != nil {
return xerrors.Errorf("unmarshal %q: %w", configPath, err)
}
mutate(mapping)
data, err = json.MarshalIndent(mapping, "", "\t")
if err != nil {
return xerrors.Errorf("marshal %q: %w", configPath, err)
}
err = afero.WriteFile(fs, configPath, data, 0600)
if err != nil {
return xerrors.Errorf("write %q: %w", configPath, err)
}
}
return nil
}

View File

@ -0,0 +1,64 @@
package gitauth_test
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/adrg/xdg"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/gitauth"
)
func TestOverrideVSCodeConfigs(t *testing.T) {
t.Parallel()
home, err := os.UserHomeDir()
require.NoError(t, err)
configPaths := []string{
filepath.Join(xdg.DataHome, "code-server", "Machine", "settings.json"),
filepath.Join(home, ".vscode-server", "data", "Machine", "settings.json"),
}
t.Run("Create", func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
err := gitauth.OverrideVSCodeConfigs(fs)
require.NoError(t, err)
for _, configPath := range configPaths {
data, err := afero.ReadFile(fs, configPath)
require.NoError(t, err)
mapping := map[string]interface{}{}
err = json.Unmarshal(data, &mapping)
require.NoError(t, err)
require.Equal(t, false, mapping["git.useIntegratedAskPass"])
require.Equal(t, false, mapping["github.gitAuthentication"])
}
})
t.Run("Append", func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
mapping := map[string]interface{}{
"hotdogs": "something",
}
data, err := json.Marshal(mapping)
require.NoError(t, err)
for _, configPath := range configPaths {
err = afero.WriteFile(fs, configPath, data, 0600)
require.NoError(t, err)
}
err = gitauth.OverrideVSCodeConfigs(fs)
require.NoError(t, err)
for _, configPath := range configPaths {
data, err := afero.ReadFile(fs, configPath)
require.NoError(t, err)
mapping := map[string]interface{}{}
err = json.Unmarshal(data, &mapping)
require.NoError(t, err)
require.Equal(t, false, mapping["git.useIntegratedAskPass"])
require.Equal(t, false, mapping["github.gitAuthentication"])
require.Equal(t, "something", mapping["hotdogs"])
}
})
}

View File

@ -40,7 +40,8 @@ func init() {
if !ok {
return false
}
return UsernameValid(str)
valid := UsernameValid(str)
return valid == nil
}
for _, tag := range []string{"username", "template_name", "workspace_name"} {
err := validate.RegisterValidation(tag, nameValidator)

View File

@ -5,6 +5,7 @@ import (
"strings"
"github.com/moby/moby/pkg/namesgenerator"
"golang.org/x/xerrors"
)
var (
@ -13,14 +14,18 @@ var (
)
// UsernameValid returns whether the input string is a valid username.
func UsernameValid(str string) bool {
func UsernameValid(str string) error {
if len(str) > 32 {
return false
return xerrors.New("must be <= 32 characters")
}
if len(str) < 1 {
return false
return xerrors.New("must be >= 1 character")
}
return UsernameValidRegex.MatchString(str)
matched := UsernameValidRegex.MatchString(str)
if !matched {
return xerrors.New("must be alphanumeric with hyphens")
}
return nil
}
// UsernameFrom returns a best-effort username from the provided string.
@ -30,7 +35,7 @@ func UsernameValid(str string) bool {
// the username from an email address. If no success happens during
// these steps, a random username will be returned.
func UsernameFrom(str string) string {
if UsernameValid(str) {
if valid := UsernameValid(str); valid == nil {
return str
}
emailAt := strings.LastIndex(str, "@")
@ -38,7 +43,7 @@ func UsernameFrom(str string) string {
str = str[:emailAt]
}
str = usernameReplace.ReplaceAllString(str, "")
if UsernameValid(str) {
if valid := UsernameValid(str); valid == nil {
return str
}
return strings.ReplaceAll(namesgenerator.GetRandomName(1), "_", "-")

View File

@ -59,7 +59,8 @@ func TestValid(t *testing.T) {
testCase := testCase
t.Run(testCase.Username, func(t *testing.T) {
t.Parallel()
require.Equal(t, testCase.Valid, httpapi.UsernameValid(testCase.Username))
valid := httpapi.UsernameValid(testCase.Username)
require.Equal(t, testCase.Valid, valid == nil)
})
}
}
@ -91,7 +92,8 @@ func TestFrom(t *testing.T) {
t.Parallel()
converted := httpapi.UsernameFrom(testCase.From)
t.Log(converted)
require.True(t, httpapi.UsernameValid(converted))
valid := httpapi.UsernameValid(converted)
require.True(t, valid == nil)
if testCase.Match == "" {
require.NotEqual(t, testCase.From, converted)
} else {

View File

@ -261,7 +261,8 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
// The username is a required property in Coder. We make a best-effort
// attempt at using what the claims provide, but if that fails we will
// generate a random username.
if !httpapi.UsernameValid(username) {
usernameValid := httpapi.UsernameValid(username)
if usernameValid != nil {
// If no username is provided, we can default to use the email address.
// This will be converted in the from function below, so it's safe
// to keep the domain.

View File

@ -9,6 +9,7 @@ import (
"net/url"
"strings"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt"
@ -20,6 +21,7 @@ import (
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/testutil"
)
@ -37,12 +39,31 @@ func (o *oauth2Config) Exchange(context.Context, string, ...oauth2.AuthCodeOptio
return o.token, nil
}
return &oauth2.Token{
AccessToken: "token",
AccessToken: "token",
RefreshToken: "refresh",
Expiry: database.Now().Add(time.Hour),
}, nil
}
func (*oauth2Config) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource {
return nil
func (o *oauth2Config) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource {
return &oauth2TokenSource{
token: o.token,
}
}
type oauth2TokenSource struct {
token *oauth2.Token
}
func (o *oauth2TokenSource) Token() (*oauth2.Token, error) {
if o.token != nil {
return o.token, nil
}
return &oauth2.Token{
AccessToken: "token",
RefreshToken: "refresh",
Expiry: database.Now().Add(time.Hour),
}, nil
}
func TestUserAuthMethods(t *testing.T) {

View File

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net"
@ -18,6 +19,7 @@ import (
"github.com/google/uuid"
"go.opentelemetry.io/otel/trace"
"golang.org/x/mod/semver"
"golang.org/x/oauth2"
"golang.org/x/xerrors"
"nhooyr.io/websocket"
"nhooyr.io/websocket/wsjson"
@ -25,6 +27,7 @@ import (
"cdr.dev/slog"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/gitauth"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
@ -84,6 +87,7 @@ func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request)
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentMetadata{
Apps: convertApps(dbApps),
DERPMap: api.DERPMap,
GitAuthConfigs: len(api.GitAuthConfigs),
EnvironmentVariables: apiAgent.EnvironmentVariables,
StartupScript: apiAgent.StartupScript,
Directory: apiAgent.Directory,
@ -925,6 +929,272 @@ func (api *API) postWorkspaceAppHealth(rw http.ResponseWriter, r *http.Request)
httpapi.Write(r.Context(), rw, http.StatusOK, nil)
}
// postWorkspaceAgentsGitAuth returns a username and password for use
// with GIT_ASKPASS.
func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
gitURL := r.URL.Query().Get("url")
if gitURL == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Missing 'url' query parameter!",
})
return
}
// listen determines if the request will wait for a
// new token to be issued!
listen := r.URL.Query().Has("listen")
var gitAuthConfig *gitauth.Config
for _, gitAuth := range api.GitAuthConfigs {
matches := gitAuth.Regex.MatchString(gitURL)
if !matches {
continue
}
gitAuthConfig = gitAuth
}
if gitAuthConfig == nil {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: fmt.Sprintf("No git provider found for URL %q", gitURL),
})
return
}
workspaceAgent := httpmw.WorkspaceAgent(r)
// We must get the workspace to get the owner ID!
resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get workspace resource.",
Detail: err.Error(),
})
return
}
build, err := api.Database.GetWorkspaceBuildByJobID(ctx, resource.JobID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get build.",
Detail: err.Error(),
})
return
}
workspace, err := api.Database.GetWorkspaceByID(ctx, build.WorkspaceID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get workspace.",
Detail: err.Error(),
})
return
}
if listen {
// If listening we await a new token...
authChan := make(chan struct{}, 1)
cancelFunc, err := api.Pubsub.Subscribe("gitauth", func(ctx context.Context, message []byte) {
ids := strings.Split(string(message), "|")
if len(ids) != 2 {
return
}
if ids[0] != gitAuthConfig.ID {
return
}
if ids[1] != workspace.OwnerID.String() {
return
}
select {
case authChan <- struct{}{}:
default:
}
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to listen for git auth token.",
Detail: err.Error(),
})
return
}
defer cancelFunc()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return
case <-ticker.C:
case <-authChan:
}
gitAuthLink, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
ProviderID: gitAuthConfig.ID,
UserID: workspace.OwnerID,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get git auth link.",
Detail: err.Error(),
})
return
}
if gitAuthLink.OAuthExpiry.Before(database.Now()) {
continue
}
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(gitAuthConfig.Type, gitAuthLink.OAuthAccessToken))
return
}
}
// This is the URL that will redirect the user with a state token.
redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/gitauth/%s", gitAuthConfig.ID))
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to parse access URL.",
Detail: err.Error(),
})
return
}
gitAuthLink, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
ProviderID: gitAuthConfig.ID,
UserID: workspace.OwnerID,
})
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get git auth link.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentGitAuthResponse{
URL: redirectURL.String(),
})
return
}
token, err := gitAuthConfig.TokenSource(ctx, &oauth2.Token{
AccessToken: gitAuthLink.OAuthAccessToken,
RefreshToken: gitAuthLink.OAuthRefreshToken,
Expiry: gitAuthLink.OAuthExpiry,
}).Token()
if err != nil {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentGitAuthResponse{
URL: redirectURL.String(),
})
return
}
if token.AccessToken != gitAuthLink.OAuthAccessToken {
// Update it
err = api.Database.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{
ProviderID: gitAuthConfig.ID,
UserID: workspace.OwnerID,
UpdatedAt: database.Now(),
OAuthAccessToken: token.AccessToken,
OAuthRefreshToken: token.RefreshToken,
OAuthExpiry: token.Expiry,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update git auth link.",
Detail: err.Error(),
})
return
}
}
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(gitAuthConfig.Type, token.AccessToken))
}
// Provider types have different username/password formats.
func formatGitAuthAccessToken(typ codersdk.GitProvider, token string) codersdk.WorkspaceAgentGitAuthResponse {
var resp codersdk.WorkspaceAgentGitAuthResponse
switch typ {
case codersdk.GitProviderGitLab:
// https://stackoverflow.com/questions/25409700/using-gitlab-token-to-clone-without-authentication
resp = codersdk.WorkspaceAgentGitAuthResponse{
Username: "oauth2",
Password: token,
}
case codersdk.GitProviderBitBucket:
// https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/#Cloning-a-repository-with-an-access-token
resp = codersdk.WorkspaceAgentGitAuthResponse{
Username: "x-token-auth",
Password: token,
}
default:
resp = codersdk.WorkspaceAgentGitAuthResponse{
Username: token,
}
}
return resp
}
func (api *API) gitAuthCallback(gitAuthConfig *gitauth.Config) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
state = httpmw.OAuth2(r)
apiKey = httpmw.APIKey(r)
)
_, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
ProviderID: gitAuthConfig.ID,
UserID: apiKey.UserID,
})
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to get git auth link.",
Detail: err.Error(),
})
return
}
_, err = api.Database.InsertGitAuthLink(ctx, database.InsertGitAuthLinkParams{
ProviderID: gitAuthConfig.ID,
UserID: apiKey.UserID,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
OAuthAccessToken: state.Token.AccessToken,
OAuthRefreshToken: state.Token.RefreshToken,
OAuthExpiry: state.Token.Expiry,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to insert git auth link.",
Detail: err.Error(),
})
return
}
} else {
err = api.Database.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{
ProviderID: gitAuthConfig.ID,
UserID: apiKey.UserID,
UpdatedAt: database.Now(),
OAuthAccessToken: state.Token.AccessToken,
OAuthRefreshToken: state.Token.RefreshToken,
OAuthExpiry: state.Token.Expiry,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to update git auth link.",
Detail: err.Error(),
})
return
}
}
err = api.Pubsub.Publish("gitauth", []byte(fmt.Sprintf("%s|%s", gitAuthConfig.ID, apiKey.UserID)))
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to publish auth update.",
Detail: err.Error(),
})
return
}
// This is a nicely rendered screen on the frontend
http.Redirect(rw, r, "/gitauth", http.StatusTemporaryRedirect)
}
}
// wsNetConn wraps net.Conn created by websocket.NetConn(). Cancel func
// is called if a read or write error is encountered.
type wsNetConn struct {

View File

@ -6,6 +6,8 @@ import (
"encoding/json"
"fmt"
"net"
"net/http"
"regexp"
"runtime"
"strconv"
"strings"
@ -13,12 +15,14 @@ import (
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/agent"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/gitauth"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
@ -718,3 +722,216 @@ func TestWorkspaceAgentAppHealth(t *testing.T) {
require.NoError(t, err)
require.EqualValues(t, codersdk.WorkspaceAppHealthUnhealthy, metadata.Apps[1].Health)
}
// nolint:bodyclose
func TestWorkspaceAgentsGitAuth(t *testing.T) {
t.Parallel()
t.Run("NoMatchingConfig", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{},
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionDryRun: echo.ProvisionComplete,
Provision: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Auth: &proto.Agent_Token{
Token: authToken,
},
}},
}},
},
},
}},
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := codersdk.New(client.URL)
agentClient.SessionToken = authToken
_, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com", false)
var apiError *codersdk.Error
require.ErrorAs(t, err, &apiError)
require.Equal(t, http.StatusNotFound, apiError.StatusCode())
})
t.Run("ReturnsURL", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{{
OAuth2Config: &oauth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.GitProviderGitHub,
}},
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionDryRun: echo.ProvisionComplete,
Provision: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Auth: &proto.Agent_Token{
Token: authToken,
},
}},
}},
},
},
}},
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := codersdk.New(client.URL)
agentClient.SessionToken = authToken
token, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", false)
require.NoError(t, err)
require.True(t, strings.HasSuffix(token.URL, fmt.Sprintf("/gitauth/%s", "github")))
})
t.Run("UnauthorizedCallback", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{{
OAuth2Config: &oauth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.GitProviderGitHub,
}},
})
resp := gitAuthCallback(t, "github", client)
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("AuthorizedCallback", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{{
OAuth2Config: &oauth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.GitProviderGitHub,
}},
})
_ = coderdtest.CreateFirstUser(t, client)
resp := gitAuthCallback(t, "github", client)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
location, err := resp.Location()
require.NoError(t, err)
require.Equal(t, "/gitauth", location.Path)
// Callback again to simulate updating the token.
resp = gitAuthCallback(t, "github", client)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
})
t.Run("FullFlow", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{{
OAuth2Config: &oauth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.GitProviderGitHub,
}},
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionDryRun: echo.ProvisionComplete,
Provision: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Auth: &proto.Agent_Token{
Token: authToken,
},
}},
}},
},
},
}},
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := codersdk.New(client.URL)
agentClient.SessionToken = authToken
token, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", false)
require.NoError(t, err)
require.NotEmpty(t, token.URL)
// Start waiting for the token callback...
tokenChan := make(chan codersdk.WorkspaceAgentGitAuthResponse, 1)
go func() {
token, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", true)
assert.NoError(t, err)
tokenChan <- token
}()
time.Sleep(250 * time.Millisecond)
resp := gitAuthCallback(t, "github", client)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
token = <-tokenChan
require.Equal(t, "token", token.Username)
token, err = agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", false)
require.NoError(t, err)
})
}
func gitAuthCallback(t *testing.T, id string, client *codersdk.Client) *http.Response {
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
state := "somestate"
oauthURL, err := client.URL.Parse(fmt.Sprintf("/gitauth/%s/callback?code=asd&state=%s", id, state))
require.NoError(t, err)
req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil)
require.NoError(t, err)
req.AddCookie(&http.Cookie{
Name: codersdk.OAuth2StateKey,
Value: state,
})
req.AddCookie(&http.Cookie{
Name: codersdk.SessionTokenKey,
Value: client.SessionToken,
})
res, err := client.HTTPClient.Do(req)
require.NoError(t, err)
t.Cleanup(func() {
_ = res.Body.Close()
})
return res
}