mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
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:
@ -3,6 +3,7 @@ package coderd
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@ -30,6 +31,7 @@ import (
|
||||
"github.com/coder/coder/coderd/audit"
|
||||
"github.com/coder/coder/coderd/awsidentity"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/gitauth"
|
||||
"github.com/coder/coder/coderd/gitsshkey"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
@ -82,6 +84,7 @@ type Options struct {
|
||||
Telemetry telemetry.Reporter
|
||||
TracerProvider trace.TracerProvider
|
||||
AutoImportTemplates []AutoImportTemplate
|
||||
GitAuthConfigs []*gitauth.Config
|
||||
RealIPConfig *httpmw.RealIPConfig
|
||||
|
||||
// TLSCertificates is used to mesh DERP servers securely.
|
||||
@ -262,6 +265,17 @@ func New(options *Options) *API {
|
||||
})
|
||||
})
|
||||
|
||||
r.Route("/gitauth", func(r chi.Router) {
|
||||
for _, gitAuthConfig := range options.GitAuthConfigs {
|
||||
r.Route(fmt.Sprintf("/%s", gitAuthConfig.ID), func(r chi.Router) {
|
||||
r.Use(
|
||||
httpmw.ExtractOAuth2(gitAuthConfig),
|
||||
apiKeyMiddleware,
|
||||
)
|
||||
r.Get("/callback", api.gitAuthCallback(gitAuthConfig))
|
||||
})
|
||||
}
|
||||
})
|
||||
r.Route("/api/v2", func(r chi.Router) {
|
||||
api.APIHandler = r
|
||||
|
||||
@ -474,6 +488,7 @@ func New(options *Options) *API {
|
||||
r.Get("/metadata", api.workspaceAgentMetadata)
|
||||
r.Post("/version", api.postWorkspaceAgentVersion)
|
||||
r.Post("/app-health", api.postWorkspaceAppHealth)
|
||||
r.Get("/gitauth", api.workspaceAgentsGitAuth)
|
||||
r.Get("/gitsshkey", api.agentGitSSHKey)
|
||||
r.Get("/coordinate", api.workspaceAgentCoordinate)
|
||||
r.Get("/report-stats", api.workspaceAgentReportStats)
|
||||
|
@ -57,6 +57,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
|
||||
"POST:/api/v2/workspaceagents/aws-instance-identity": {NoAuthorize: true},
|
||||
"POST:/api/v2/workspaceagents/azure-instance-identity": {NoAuthorize: true},
|
||||
"POST:/api/v2/workspaceagents/google-instance-identity": {NoAuthorize: true},
|
||||
"GET:/api/v2/workspaceagents/me/gitauth": {NoAuthorize: true},
|
||||
"GET:/api/v2/workspaceagents/me/gitsshkey": {NoAuthorize: true},
|
||||
"GET:/api/v2/workspaceagents/me/metadata": {NoAuthorize: true},
|
||||
"GET:/api/v2/workspaceagents/me/coordinate": {NoAuthorize: true},
|
||||
|
@ -55,6 +55,7 @@ import (
|
||||
"github.com/coder/coder/coderd/awsidentity"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/coderd/gitauth"
|
||||
"github.com/coder/coder/coderd/gitsshkey"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
@ -88,6 +89,7 @@ type Options struct {
|
||||
AutobuildStats chan<- executor.Stats
|
||||
Auditor audit.Auditor
|
||||
TLSCertificates []tls.Certificate
|
||||
GitAuthConfigs []*gitauth.Config
|
||||
|
||||
// IncludeProvisionerDaemon when true means to start an in-memory provisionerD
|
||||
IncludeProvisionerDaemon bool
|
||||
@ -235,6 +237,7 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can
|
||||
Database: options.Database,
|
||||
Pubsub: options.Pubsub,
|
||||
Experimental: options.Experimental,
|
||||
GitAuthConfigs: options.GitAuthConfigs,
|
||||
|
||||
Auditor: options.Auditor,
|
||||
AWSCertificates: options.AWSCertificates,
|
||||
|
@ -34,6 +34,7 @@ func New() database.Store {
|
||||
organizationMembers: make([]database.OrganizationMember, 0),
|
||||
organizations: make([]database.Organization, 0),
|
||||
users: make([]database.User, 0),
|
||||
gitAuthLinks: make([]database.GitAuthLink, 0),
|
||||
groups: make([]database.Group, 0),
|
||||
groupMembers: make([]database.GroupMember, 0),
|
||||
auditLogs: make([]database.AuditLog, 0),
|
||||
@ -90,6 +91,7 @@ type data struct {
|
||||
agentStats []database.AgentStat
|
||||
auditLogs []database.AuditLog
|
||||
files []database.File
|
||||
gitAuthLinks []database.GitAuthLink
|
||||
gitSSHKey []database.GitSSHKey
|
||||
groups []database.Group
|
||||
groupMembers []database.GroupMember
|
||||
@ -3438,3 +3440,54 @@ func (q *fakeQuerier) GetReplicasUpdatedAfter(_ context.Context, updatedAt time.
|
||||
}
|
||||
return replicas, nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) GetGitAuthLink(_ context.Context, arg database.GetGitAuthLinkParams) (database.GitAuthLink, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
for _, gitAuthLink := range q.gitAuthLinks {
|
||||
if arg.UserID != gitAuthLink.UserID {
|
||||
continue
|
||||
}
|
||||
if arg.ProviderID != gitAuthLink.ProviderID {
|
||||
continue
|
||||
}
|
||||
return gitAuthLink, nil
|
||||
}
|
||||
return database.GitAuthLink{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) InsertGitAuthLink(_ context.Context, arg database.InsertGitAuthLinkParams) (database.GitAuthLink, error) {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
// nolint:gosimple
|
||||
gitAuthLink := database.GitAuthLink{
|
||||
ProviderID: arg.ProviderID,
|
||||
UserID: arg.UserID,
|
||||
CreatedAt: arg.CreatedAt,
|
||||
UpdatedAt: arg.UpdatedAt,
|
||||
OAuthAccessToken: arg.OAuthAccessToken,
|
||||
OAuthRefreshToken: arg.OAuthRefreshToken,
|
||||
OAuthExpiry: arg.OAuthExpiry,
|
||||
}
|
||||
q.gitAuthLinks = append(q.gitAuthLinks, gitAuthLink)
|
||||
return gitAuthLink, nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) UpdateGitAuthLink(_ context.Context, arg database.UpdateGitAuthLinkParams) error {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
for index, gitAuthLink := range q.gitAuthLinks {
|
||||
if gitAuthLink.ProviderID != arg.ProviderID {
|
||||
continue
|
||||
}
|
||||
if gitAuthLink.UserID != arg.UserID {
|
||||
continue
|
||||
}
|
||||
gitAuthLink.UpdatedAt = arg.UpdatedAt
|
||||
gitAuthLink.OAuthAccessToken = arg.OAuthAccessToken
|
||||
gitAuthLink.OAuthRefreshToken = arg.OAuthRefreshToken
|
||||
gitAuthLink.OAuthExpiry = arg.OAuthExpiry
|
||||
q.gitAuthLinks[index] = gitAuthLink
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
13
coderd/database/dump.sql
generated
13
coderd/database/dump.sql
generated
@ -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);
|
||||
|
||||
|
1
coderd/database/migrations/000064_gitauth.down.sql
Normal file
1
coderd/database/migrations/000064_gitauth.down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE git_auth_links;
|
10
coderd/database/migrations/000064_gitauth.up.sql
Normal file
10
coderd/database/migrations/000064_gitauth.up.sql
Normal 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)
|
||||
);
|
@ -428,6 +428,16 @@ type File struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
}
|
||||
|
||||
type GitAuthLink struct {
|
||||
ProviderID string `db:"provider_id" json:"provider_id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"`
|
||||
OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"`
|
||||
OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"`
|
||||
}
|
||||
|
||||
type GitSSHKey struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
|
@ -44,6 +44,7 @@ type sqlcQuerier interface {
|
||||
GetDeploymentID(ctx context.Context) (string, error)
|
||||
GetFileByHashAndCreator(ctx context.Context, arg GetFileByHashAndCreatorParams) (File, error)
|
||||
GetFileByID(ctx context.Context, id uuid.UUID) (File, error)
|
||||
GetGitAuthLink(ctx context.Context, arg GetGitAuthLinkParams) (GitAuthLink, error)
|
||||
GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error)
|
||||
GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error)
|
||||
GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error)
|
||||
@ -132,6 +133,7 @@ type sqlcQuerier interface {
|
||||
InsertDERPMeshKey(ctx context.Context, value string) error
|
||||
InsertDeploymentID(ctx context.Context, value string) error
|
||||
InsertFile(ctx context.Context, arg InsertFileParams) (File, error)
|
||||
InsertGitAuthLink(ctx context.Context, arg InsertGitAuthLinkParams) (GitAuthLink, error)
|
||||
InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error)
|
||||
InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error)
|
||||
InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error
|
||||
@ -157,6 +159,7 @@ type sqlcQuerier interface {
|
||||
ParameterValue(ctx context.Context, id uuid.UUID) (ParameterValue, error)
|
||||
ParameterValues(ctx context.Context, arg ParameterValuesParams) ([]ParameterValue, error)
|
||||
UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error
|
||||
UpdateGitAuthLink(ctx context.Context, arg UpdateGitAuthLinkParams) error
|
||||
UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error)
|
||||
UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error)
|
||||
UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error)
|
||||
|
@ -753,6 +753,113 @@ func (q *sqlQuerier) InsertFile(ctx context.Context, arg InsertFileParams) (File
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getGitAuthLink = `-- name: GetGitAuthLink :one
|
||||
SELECT provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry FROM git_auth_links WHERE provider_id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type GetGitAuthLinkParams struct {
|
||||
ProviderID string `db:"provider_id" json:"provider_id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) GetGitAuthLink(ctx context.Context, arg GetGitAuthLinkParams) (GitAuthLink, error) {
|
||||
row := q.db.QueryRowContext(ctx, getGitAuthLink, arg.ProviderID, arg.UserID)
|
||||
var i GitAuthLink
|
||||
err := row.Scan(
|
||||
&i.ProviderID,
|
||||
&i.UserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.OAuthAccessToken,
|
||||
&i.OAuthRefreshToken,
|
||||
&i.OAuthExpiry,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertGitAuthLink = `-- name: InsertGitAuthLink :one
|
||||
INSERT INTO git_auth_links (
|
||||
provider_id,
|
||||
user_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
oauth_access_token,
|
||||
oauth_refresh_token,
|
||||
oauth_expiry
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7
|
||||
) RETURNING provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry
|
||||
`
|
||||
|
||||
type InsertGitAuthLinkParams struct {
|
||||
ProviderID string `db:"provider_id" json:"provider_id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"`
|
||||
OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"`
|
||||
OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertGitAuthLink(ctx context.Context, arg InsertGitAuthLinkParams) (GitAuthLink, error) {
|
||||
row := q.db.QueryRowContext(ctx, insertGitAuthLink,
|
||||
arg.ProviderID,
|
||||
arg.UserID,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
arg.OAuthAccessToken,
|
||||
arg.OAuthRefreshToken,
|
||||
arg.OAuthExpiry,
|
||||
)
|
||||
var i GitAuthLink
|
||||
err := row.Scan(
|
||||
&i.ProviderID,
|
||||
&i.UserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.OAuthAccessToken,
|
||||
&i.OAuthRefreshToken,
|
||||
&i.OAuthExpiry,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateGitAuthLink = `-- name: UpdateGitAuthLink :exec
|
||||
UPDATE git_auth_links SET
|
||||
updated_at = $3,
|
||||
oauth_access_token = $4,
|
||||
oauth_refresh_token = $5,
|
||||
oauth_expiry = $6
|
||||
WHERE provider_id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type UpdateGitAuthLinkParams struct {
|
||||
ProviderID string `db:"provider_id" json:"provider_id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"`
|
||||
OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"`
|
||||
OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateGitAuthLink(ctx context.Context, arg UpdateGitAuthLinkParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateGitAuthLink,
|
||||
arg.ProviderID,
|
||||
arg.UserID,
|
||||
arg.UpdatedAt,
|
||||
arg.OAuthAccessToken,
|
||||
arg.OAuthRefreshToken,
|
||||
arg.OAuthExpiry,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteGitSSHKey = `-- name: DeleteGitSSHKey :exec
|
||||
DELETE FROM
|
||||
gitsshkeys
|
||||
|
29
coderd/database/queries/gitauth.sql
Normal file
29
coderd/database/queries/gitauth.sql
Normal 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;
|
@ -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
70
coderd/gitauth/askpass.go
Normal 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
|
||||
}
|
72
coderd/gitauth/askpass_test.go
Normal file
72
coderd/gitauth/askpass_test.go
Normal 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
103
coderd/gitauth/config.go
Normal 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
|
||||
}
|
78
coderd/gitauth/config_test.go
Normal file
78
coderd/gitauth/config_test.go
Normal 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
83
coderd/gitauth/oauth.go
Normal 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", ""),
|
||||
)...,
|
||||
)
|
||||
}
|
9
coderd/gitauth/oauth_test.go
Normal file
9
coderd/gitauth/oauth_test.go
Normal file
@ -0,0 +1,9 @@
|
||||
package gitauth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOAuthJWTConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
}
|
81
coderd/gitauth/vscode.go
Normal file
81
coderd/gitauth/vscode.go
Normal 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
|
||||
}
|
64
coderd/gitauth/vscode_test.go
Normal file
64
coderd/gitauth/vscode_test.go
Normal 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"])
|
||||
}
|
||||
})
|
||||
}
|
@ -40,7 +40,8 @@ func init() {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return UsernameValid(str)
|
||||
valid := UsernameValid(str)
|
||||
return valid == nil
|
||||
}
|
||||
for _, tag := range []string{"username", "template_name", "workspace_name"} {
|
||||
err := validate.RegisterValidation(tag, nameValidator)
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/moby/moby/pkg/namesgenerator"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -13,14 +14,18 @@ var (
|
||||
)
|
||||
|
||||
// UsernameValid returns whether the input string is a valid username.
|
||||
func UsernameValid(str string) bool {
|
||||
func UsernameValid(str string) error {
|
||||
if len(str) > 32 {
|
||||
return false
|
||||
return xerrors.New("must be <= 32 characters")
|
||||
}
|
||||
if len(str) < 1 {
|
||||
return false
|
||||
return xerrors.New("must be >= 1 character")
|
||||
}
|
||||
return UsernameValidRegex.MatchString(str)
|
||||
matched := UsernameValidRegex.MatchString(str)
|
||||
if !matched {
|
||||
return xerrors.New("must be alphanumeric with hyphens")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UsernameFrom returns a best-effort username from the provided string.
|
||||
@ -30,7 +35,7 @@ func UsernameValid(str string) bool {
|
||||
// the username from an email address. If no success happens during
|
||||
// these steps, a random username will be returned.
|
||||
func UsernameFrom(str string) string {
|
||||
if UsernameValid(str) {
|
||||
if valid := UsernameValid(str); valid == nil {
|
||||
return str
|
||||
}
|
||||
emailAt := strings.LastIndex(str, "@")
|
||||
@ -38,7 +43,7 @@ func UsernameFrom(str string) string {
|
||||
str = str[:emailAt]
|
||||
}
|
||||
str = usernameReplace.ReplaceAllString(str, "")
|
||||
if UsernameValid(str) {
|
||||
if valid := UsernameValid(str); valid == nil {
|
||||
return str
|
||||
}
|
||||
return strings.ReplaceAll(namesgenerator.GetRandomName(1), "_", "-")
|
||||
|
@ -59,7 +59,8 @@ func TestValid(t *testing.T) {
|
||||
testCase := testCase
|
||||
t.Run(testCase.Username, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, testCase.Valid, httpapi.UsernameValid(testCase.Username))
|
||||
valid := httpapi.UsernameValid(testCase.Username)
|
||||
require.Equal(t, testCase.Valid, valid == nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -91,7 +92,8 @@ func TestFrom(t *testing.T) {
|
||||
t.Parallel()
|
||||
converted := httpapi.UsernameFrom(testCase.From)
|
||||
t.Log(converted)
|
||||
require.True(t, httpapi.UsernameValid(converted))
|
||||
valid := httpapi.UsernameValid(converted)
|
||||
require.True(t, valid == nil)
|
||||
if testCase.Match == "" {
|
||||
require.NotEqual(t, testCase.From, converted)
|
||||
} else {
|
||||
|
@ -261,7 +261,8 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
||||
// The username is a required property in Coder. We make a best-effort
|
||||
// attempt at using what the claims provide, but if that fails we will
|
||||
// generate a random username.
|
||||
if !httpapi.UsernameValid(username) {
|
||||
usernameValid := httpapi.UsernameValid(username)
|
||||
if usernameValid != nil {
|
||||
// If no username is provided, we can default to use the email address.
|
||||
// This will be converted in the from function below, so it's safe
|
||||
// to keep the domain.
|
||||
|
@ -9,6 +9,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/golang-jwt/jwt"
|
||||
@ -20,6 +21,7 @@ import (
|
||||
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
@ -37,12 +39,31 @@ func (o *oauth2Config) Exchange(context.Context, string, ...oauth2.AuthCodeOptio
|
||||
return o.token, nil
|
||||
}
|
||||
return &oauth2.Token{
|
||||
AccessToken: "token",
|
||||
AccessToken: "token",
|
||||
RefreshToken: "refresh",
|
||||
Expiry: database.Now().Add(time.Hour),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (*oauth2Config) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource {
|
||||
return nil
|
||||
func (o *oauth2Config) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource {
|
||||
return &oauth2TokenSource{
|
||||
token: o.token,
|
||||
}
|
||||
}
|
||||
|
||||
type oauth2TokenSource struct {
|
||||
token *oauth2.Token
|
||||
}
|
||||
|
||||
func (o *oauth2TokenSource) Token() (*oauth2.Token, error) {
|
||||
if o.token != nil {
|
||||
return o.token, nil
|
||||
}
|
||||
return &oauth2.Token{
|
||||
AccessToken: "token",
|
||||
RefreshToken: "refresh",
|
||||
Expiry: database.Now().Add(time.Hour),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestUserAuthMethods(t *testing.T) {
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@ -18,6 +19,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"golang.org/x/mod/semver"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/xerrors"
|
||||
"nhooyr.io/websocket"
|
||||
"nhooyr.io/websocket/wsjson"
|
||||
@ -25,6 +27,7 @@ import (
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/gitauth"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
@ -84,6 +87,7 @@ func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request)
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentMetadata{
|
||||
Apps: convertApps(dbApps),
|
||||
DERPMap: api.DERPMap,
|
||||
GitAuthConfigs: len(api.GitAuthConfigs),
|
||||
EnvironmentVariables: apiAgent.EnvironmentVariables,
|
||||
StartupScript: apiAgent.StartupScript,
|
||||
Directory: apiAgent.Directory,
|
||||
@ -925,6 +929,272 @@ func (api *API) postWorkspaceAppHealth(rw http.ResponseWriter, r *http.Request)
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
// postWorkspaceAgentsGitAuth returns a username and password for use
|
||||
// with GIT_ASKPASS.
|
||||
func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
gitURL := r.URL.Query().Get("url")
|
||||
if gitURL == "" {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Missing 'url' query parameter!",
|
||||
})
|
||||
return
|
||||
}
|
||||
// listen determines if the request will wait for a
|
||||
// new token to be issued!
|
||||
listen := r.URL.Query().Has("listen")
|
||||
|
||||
var gitAuthConfig *gitauth.Config
|
||||
for _, gitAuth := range api.GitAuthConfigs {
|
||||
matches := gitAuth.Regex.MatchString(gitURL)
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
gitAuthConfig = gitAuth
|
||||
}
|
||||
if gitAuthConfig == nil {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: fmt.Sprintf("No git provider found for URL %q", gitURL),
|
||||
})
|
||||
return
|
||||
}
|
||||
workspaceAgent := httpmw.WorkspaceAgent(r)
|
||||
// We must get the workspace to get the owner ID!
|
||||
resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get workspace resource.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
build, err := api.Database.GetWorkspaceBuildByJobID(ctx, resource.JobID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get build.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
workspace, err := api.Database.GetWorkspaceByID(ctx, build.WorkspaceID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get workspace.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if listen {
|
||||
// If listening we await a new token...
|
||||
authChan := make(chan struct{}, 1)
|
||||
cancelFunc, err := api.Pubsub.Subscribe("gitauth", func(ctx context.Context, message []byte) {
|
||||
ids := strings.Split(string(message), "|")
|
||||
if len(ids) != 2 {
|
||||
return
|
||||
}
|
||||
if ids[0] != gitAuthConfig.ID {
|
||||
return
|
||||
}
|
||||
if ids[1] != workspace.OwnerID.String() {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case authChan <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to listen for git auth token.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer cancelFunc()
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
case <-authChan:
|
||||
}
|
||||
gitAuthLink, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
|
||||
ProviderID: gitAuthConfig.ID,
|
||||
UserID: workspace.OwnerID,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get git auth link.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if gitAuthLink.OAuthExpiry.Before(database.Now()) {
|
||||
continue
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(gitAuthConfig.Type, gitAuthLink.OAuthAccessToken))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// This is the URL that will redirect the user with a state token.
|
||||
redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/gitauth/%s", gitAuthConfig.ID))
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to parse access URL.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
gitAuthLink, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
|
||||
ProviderID: gitAuthConfig.ID,
|
||||
UserID: workspace.OwnerID,
|
||||
})
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get git auth link.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentGitAuthResponse{
|
||||
URL: redirectURL.String(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := gitAuthConfig.TokenSource(ctx, &oauth2.Token{
|
||||
AccessToken: gitAuthLink.OAuthAccessToken,
|
||||
RefreshToken: gitAuthLink.OAuthRefreshToken,
|
||||
Expiry: gitAuthLink.OAuthExpiry,
|
||||
}).Token()
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentGitAuthResponse{
|
||||
URL: redirectURL.String(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if token.AccessToken != gitAuthLink.OAuthAccessToken {
|
||||
// Update it
|
||||
err = api.Database.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{
|
||||
ProviderID: gitAuthConfig.ID,
|
||||
UserID: workspace.OwnerID,
|
||||
UpdatedAt: database.Now(),
|
||||
OAuthAccessToken: token.AccessToken,
|
||||
OAuthRefreshToken: token.RefreshToken,
|
||||
OAuthExpiry: token.Expiry,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to update git auth link.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(gitAuthConfig.Type, token.AccessToken))
|
||||
}
|
||||
|
||||
// Provider types have different username/password formats.
|
||||
func formatGitAuthAccessToken(typ codersdk.GitProvider, token string) codersdk.WorkspaceAgentGitAuthResponse {
|
||||
var resp codersdk.WorkspaceAgentGitAuthResponse
|
||||
switch typ {
|
||||
case codersdk.GitProviderGitLab:
|
||||
// https://stackoverflow.com/questions/25409700/using-gitlab-token-to-clone-without-authentication
|
||||
resp = codersdk.WorkspaceAgentGitAuthResponse{
|
||||
Username: "oauth2",
|
||||
Password: token,
|
||||
}
|
||||
case codersdk.GitProviderBitBucket:
|
||||
// https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/#Cloning-a-repository-with-an-access-token
|
||||
resp = codersdk.WorkspaceAgentGitAuthResponse{
|
||||
Username: "x-token-auth",
|
||||
Password: token,
|
||||
}
|
||||
default:
|
||||
resp = codersdk.WorkspaceAgentGitAuthResponse{
|
||||
Username: token,
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func (api *API) gitAuthCallback(gitAuthConfig *gitauth.Config) http.HandlerFunc {
|
||||
return func(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
state = httpmw.OAuth2(r)
|
||||
apiKey = httpmw.APIKey(r)
|
||||
)
|
||||
|
||||
_, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
|
||||
ProviderID: gitAuthConfig.ID,
|
||||
UserID: apiKey.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to get git auth link.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = api.Database.InsertGitAuthLink(ctx, database.InsertGitAuthLinkParams{
|
||||
ProviderID: gitAuthConfig.ID,
|
||||
UserID: apiKey.UserID,
|
||||
CreatedAt: database.Now(),
|
||||
UpdatedAt: database.Now(),
|
||||
OAuthAccessToken: state.Token.AccessToken,
|
||||
OAuthRefreshToken: state.Token.RefreshToken,
|
||||
OAuthExpiry: state.Token.Expiry,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to insert git auth link.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err = api.Database.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{
|
||||
ProviderID: gitAuthConfig.ID,
|
||||
UserID: apiKey.UserID,
|
||||
UpdatedAt: database.Now(),
|
||||
OAuthAccessToken: state.Token.AccessToken,
|
||||
OAuthRefreshToken: state.Token.RefreshToken,
|
||||
OAuthExpiry: state.Token.Expiry,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to update git auth link.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = api.Pubsub.Publish("gitauth", []byte(fmt.Sprintf("%s|%s", gitAuthConfig.ID, apiKey.UserID)))
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to publish auth update.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// This is a nicely rendered screen on the frontend
|
||||
http.Redirect(rw, r, "/gitauth", http.StatusTemporaryRedirect)
|
||||
}
|
||||
}
|
||||
|
||||
// wsNetConn wraps net.Conn created by websocket.NetConn(). Cancel func
|
||||
// is called if a read or write error is encountered.
|
||||
type wsNetConn struct {
|
||||
|
@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -13,12 +15,14 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/gitauth"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
@ -718,3 +722,216 @@ func TestWorkspaceAgentAppHealth(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, codersdk.WorkspaceAppHealthUnhealthy, metadata.Apps[1].Health)
|
||||
}
|
||||
|
||||
// nolint:bodyclose
|
||||
func TestWorkspaceAgentsGitAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("NoMatchingConfig", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
GitAuthConfigs: []*gitauth.Config{},
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: authToken,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = authToken
|
||||
_, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com", false)
|
||||
var apiError *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiError)
|
||||
require.Equal(t, http.StatusNotFound, apiError.StatusCode())
|
||||
})
|
||||
t.Run("ReturnsURL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
GitAuthConfigs: []*gitauth.Config{{
|
||||
OAuth2Config: &oauth2Config{},
|
||||
ID: "github",
|
||||
Regex: regexp.MustCompile(`github\.com`),
|
||||
Type: codersdk.GitProviderGitHub,
|
||||
}},
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: authToken,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = authToken
|
||||
token, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", false)
|
||||
require.NoError(t, err)
|
||||
require.True(t, strings.HasSuffix(token.URL, fmt.Sprintf("/gitauth/%s", "github")))
|
||||
})
|
||||
t.Run("UnauthorizedCallback", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
GitAuthConfigs: []*gitauth.Config{{
|
||||
OAuth2Config: &oauth2Config{},
|
||||
ID: "github",
|
||||
Regex: regexp.MustCompile(`github\.com`),
|
||||
Type: codersdk.GitProviderGitHub,
|
||||
}},
|
||||
})
|
||||
resp := gitAuthCallback(t, "github", client)
|
||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
t.Run("AuthorizedCallback", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
GitAuthConfigs: []*gitauth.Config{{
|
||||
OAuth2Config: &oauth2Config{},
|
||||
ID: "github",
|
||||
Regex: regexp.MustCompile(`github\.com`),
|
||||
Type: codersdk.GitProviderGitHub,
|
||||
}},
|
||||
})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
resp := gitAuthCallback(t, "github", client)
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
location, err := resp.Location()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "/gitauth", location.Path)
|
||||
|
||||
// Callback again to simulate updating the token.
|
||||
resp = gitAuthCallback(t, "github", client)
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
})
|
||||
t.Run("FullFlow", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
GitAuthConfigs: []*gitauth.Config{{
|
||||
OAuth2Config: &oauth2Config{},
|
||||
ID: "github",
|
||||
Regex: regexp.MustCompile(`github\.com`),
|
||||
Type: codersdk.GitProviderGitHub,
|
||||
}},
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: authToken,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = authToken
|
||||
|
||||
token, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", false)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, token.URL)
|
||||
|
||||
// Start waiting for the token callback...
|
||||
tokenChan := make(chan codersdk.WorkspaceAgentGitAuthResponse, 1)
|
||||
go func() {
|
||||
token, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", true)
|
||||
assert.NoError(t, err)
|
||||
tokenChan <- token
|
||||
}()
|
||||
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
resp := gitAuthCallback(t, "github", client)
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
token = <-tokenChan
|
||||
require.Equal(t, "token", token.Username)
|
||||
|
||||
token, err = agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", false)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func gitAuthCallback(t *testing.T, id string, client *codersdk.Client) *http.Response {
|
||||
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
state := "somestate"
|
||||
oauthURL, err := client.URL.Parse(fmt.Sprintf("/gitauth/%s/callback?code=asd&state=%s", id, state))
|
||||
require.NoError(t, err)
|
||||
req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil)
|
||||
require.NoError(t, err)
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: codersdk.OAuth2StateKey,
|
||||
Value: state,
|
||||
})
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: codersdk.SessionTokenKey,
|
||||
Value: client.SessionToken,
|
||||
})
|
||||
res, err := client.HTTPClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = res.Body.Close()
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
Reference in New Issue
Block a user