feat: Validate Git tokens before consuming them (#5167)

* feat: Validate Git tokens before consuming them

This works the exact same way that the Git credential manager does. It ensures the user token is valid before returning it to the client.

It's been manually tested on GitHub, GitLab, and BitBucket.

* Fix requested changes
This commit is contained in:
Kyle Carberry 2022-11-29 19:08:27 +01:00 committed by GitHub
parent a8f5af1245
commit 8b73844f69
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 149 additions and 2 deletions

View File

@ -179,6 +179,7 @@ func TestConfig(t *testing.T) {
"CODER_GITAUTH_0_CLIENT_SECRET": "secret",
"CODER_GITAUTH_0_AUTH_URL": "https://auth.com",
"CODER_GITAUTH_0_TOKEN_URL": "https://token.com",
"CODER_GITAUTH_0_VALIDATE_URL": "https://validate.com",
"CODER_GITAUTH_0_REGEX": "github.com",
"CODER_GITAUTH_0_SCOPES": "read write",
"CODER_GITAUTH_0_NO_REFRESH": "true",
@ -200,6 +201,7 @@ func TestConfig(t *testing.T) {
ClientSecret: "secret",
AuthURL: "https://auth.com",
TokenURL: "https://token.com",
ValidateURL: "https://validate.com",
Regex: "github.com",
Scopes: []string{"read", "write"},
NoRefresh: true,

View File

@ -62,7 +62,7 @@ func gitAskpass() *cobra.Command {
if err != nil {
continue
}
cmd.Printf("\nYou've been authenticated with Git!\n")
cmd.Printf("You've been authenticated with Git!\n")
break
}
}

View File

@ -28,6 +28,10 @@ type Config struct {
// Some organizations have security policies that require
// re-authentication for every token.
NoRefresh bool
// ValidateURL ensures an access token is valid before
// returning it to the user. If omitted, tokens will
// not be validated before being returned.
ValidateURL string
}
// ConvertConfig converts the YAML configuration entry to the
@ -101,6 +105,9 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con
if entry.Scopes != nil && len(entry.Scopes) > 0 {
oauth2Config.Scopes = entry.Scopes
}
if entry.ValidateURL == "" {
entry.ValidateURL = validateURL[typ]
}
var oauthConfig httpmw.OAuth2Config = oauth2Config
// Azure DevOps uses JWT token authentication!
@ -114,6 +121,7 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con
Regex: regex,
Type: typ,
NoRefresh: entry.NoRefresh,
ValidateURL: validateURL[typ],
})
}
return configs, nil

View File

@ -29,10 +29,17 @@ var endpoint = map[codersdk.GitProvider]oauth2.Endpoint{
codersdk.GitProviderGitHub: github.Endpoint,
}
// validateURL contains defaults for each provider.
var validateURL = map[codersdk.GitProvider]string{
codersdk.GitProviderGitHub: "https://api.github.com/user",
codersdk.GitProviderGitLab: "https://gitlab.com/oauth/token/info",
codersdk.GitProviderBitBucket: "https://api.bitbucket.org/2.0/user",
}
// scope contains defaults for each Git provider.
var scope = map[codersdk.GitProvider][]string{
codersdk.GitProviderAzureDevops: {"vso.code_write"},
codersdk.GitProviderBitBucket: {"repository:write"},
codersdk.GitProviderBitBucket: {"account", "repository:write"},
codersdk.GitProviderGitLab: {"write_repository"},
codersdk.GitProviderGitHub: {"repo"},
}

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
@ -1159,6 +1160,19 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
if gitAuthLink.OAuthExpiry.Before(database.Now()) {
continue
}
if gitAuthConfig.ValidateURL != "" {
valid, err := validateGitToken(ctx, gitAuthConfig.ValidateURL, gitAuthLink.OAuthAccessToken)
if err != nil {
api.Logger.Warn(ctx, "failed to validate git auth token",
slog.F("workspace_owner_id", workspace.OwnerID.String()),
slog.F("validate_url", gitAuthConfig.ValidateURL),
slog.Error(err),
)
}
if !valid {
continue
}
}
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(gitAuthConfig.Type, gitAuthLink.OAuthAccessToken))
return
}
@ -1214,6 +1228,24 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
return
}
if gitAuthConfig.ValidateURL != "" {
valid, err := validateGitToken(ctx, gitAuthConfig.ValidateURL, token.AccessToken)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to validate Git authentication token.",
Detail: err.Error(),
})
return
}
if !valid {
// The token is no longer valid!
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{
@ -1235,6 +1267,30 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(gitAuthConfig.Type, token.AccessToken))
}
// validateGitToken ensures the git token provided is valid
// against the provided URL.
func validateGitToken(ctx context.Context, validateURL, token string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, validateURL, nil)
if err != nil {
return false, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
res, err := http.DefaultClient.Do(req)
if err != nil {
return false, err
}
defer res.Body.Close()
if res.StatusCode == http.StatusUnauthorized {
// The token is no longer valid!
return false, nil
}
if res.StatusCode != http.StatusOK {
data, _ := io.ReadAll(res.Body)
return false, xerrors.Errorf("git token validation failed: status %d: body: %s", res.StatusCode, data)
}
return true, nil
}
// Provider types have different username/password formats.
func formatGitAuthAccessToken(typ codersdk.GitProvider, token string) codersdk.WorkspaceAgentGitAuthResponse {
var resp codersdk.WorkspaceAgentGitAuthResponse

View File

@ -7,6 +7,7 @@ import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"regexp"
"runtime"
"strconv"
@ -934,6 +935,77 @@ func TestWorkspaceAgentsGitAuth(t *testing.T) {
resp = gitAuthCallback(t, "github", client)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
})
t.Run("ValidateURL", func(t *testing.T) {
t.Parallel()
ctx, cancelFunc := testutil.Context(t)
defer cancelFunc()
srv := httptest.NewServer(nil)
defer srv.Close()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{{
ValidateURL: srv.URL,
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,
ProvisionPlan: echo.ProvisionComplete,
ProvisionApply: []*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.SetSessionToken(authToken)
resp := gitAuthCallback(t, "github", client)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
// If the validation URL says unauthorized, the callback
// URL to re-authenticate should be returned.
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
})
res, err := agentClient.WorkspaceAgentGitAuth(ctx, "github.com/asd/asd", false)
require.NoError(t, err)
require.NotEmpty(t, res.URL)
// If the validation URL gives a non-OK status code, this
// should be treated as an internal server error.
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("Something went wrong!"))
})
_, err = agentClient.WorkspaceAgentGitAuth(ctx, "github.com/asd/asd", false)
var apiError *codersdk.Error
require.ErrorAs(t, err, &apiError)
require.Equal(t, http.StatusInternalServerError, apiError.StatusCode())
require.Equal(t, "git token validation failed: status 403: body: Something went wrong!", apiError.Detail)
})
t.Run("ExpiredNoRefresh", func(t *testing.T) {
t.Parallel()

View File

@ -126,6 +126,7 @@ type GitAuthConfig struct {
ClientSecret string `json:"-" yaml:"client_secret"`
AuthURL string `json:"auth_url"`
TokenURL string `json:"token_url"`
ValidateURL string `json:"validate_url"`
Regex string `json:"regex"`
NoRefresh bool `json:"no_refresh"`
Scopes []string `json:"scopes"`

View File

@ -355,6 +355,7 @@ export interface GitAuthConfig {
readonly client_id: string
readonly auth_url: string
readonly token_url: string
readonly validate_url: string
readonly regex: string
readonly no_refresh: boolean
readonly scopes: string[]