mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08: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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user