Skip to content

Commit 7a0a683

Browse files
kylecarbspull[bot]
authored andcommitted
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
1 parent e3c086a commit 7a0a683

File tree

8 files changed

+149
-2
lines changed

8 files changed

+149
-2
lines changed

cli/deployment/config_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ func TestConfig(t *testing.T) {
179179
"CODER_GITAUTH_0_CLIENT_SECRET": "secret",
180180
"CODER_GITAUTH_0_AUTH_URL": "https://auth.com",
181181
"CODER_GITAUTH_0_TOKEN_URL": "https://token.com",
182+
"CODER_GITAUTH_0_VALIDATE_URL": "https://validate.com",
182183
"CODER_GITAUTH_0_REGEX": "github.com",
183184
"CODER_GITAUTH_0_SCOPES": "read write",
184185
"CODER_GITAUTH_0_NO_REFRESH": "true",
@@ -200,6 +201,7 @@ func TestConfig(t *testing.T) {
200201
ClientSecret: "secret",
201202
AuthURL: "https://auth.com",
202203
TokenURL: "https://token.com",
204+
ValidateURL: "https://validate.com",
203205
Regex: "github.com",
204206
Scopes: []string{"read", "write"},
205207
NoRefresh: true,

cli/gitaskpass.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func gitAskpass() *cobra.Command {
6262
if err != nil {
6363
continue
6464
}
65-
cmd.Printf("\nYou've been authenticated with Git!\n")
65+
cmd.Printf("You've been authenticated with Git!\n")
6666
break
6767
}
6868
}

coderd/gitauth/config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ type Config struct {
2828
// Some organizations have security policies that require
2929
// re-authentication for every token.
3030
NoRefresh bool
31+
// ValidateURL ensures an access token is valid before
32+
// returning it to the user. If omitted, tokens will
33+
// not be validated before being returned.
34+
ValidateURL string
3135
}
3236

3337
// ConvertConfig converts the YAML configuration entry to the
@@ -101,6 +105,9 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con
101105
if entry.Scopes != nil && len(entry.Scopes) > 0 {
102106
oauth2Config.Scopes = entry.Scopes
103107
}
108+
if entry.ValidateURL == "" {
109+
entry.ValidateURL = validateURL[typ]
110+
}
104111

105112
var oauthConfig httpmw.OAuth2Config = oauth2Config
106113
// Azure DevOps uses JWT token authentication!
@@ -114,6 +121,7 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con
114121
Regex: regex,
115122
Type: typ,
116123
NoRefresh: entry.NoRefresh,
124+
ValidateURL: validateURL[typ],
117125
})
118126
}
119127
return configs, nil

coderd/gitauth/oauth.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,17 @@ var endpoint = map[codersdk.GitProvider]oauth2.Endpoint{
2929
codersdk.GitProviderGitHub: github.Endpoint,
3030
}
3131

32+
// validateURL contains defaults for each provider.
33+
var validateURL = map[codersdk.GitProvider]string{
34+
codersdk.GitProviderGitHub: "https://api.github.com/user",
35+
codersdk.GitProviderGitLab: "https://gitlab.com/oauth/token/info",
36+
codersdk.GitProviderBitBucket: "https://api.bitbucket.org/2.0/user",
37+
}
38+
3239
// scope contains defaults for each Git provider.
3340
var scope = map[codersdk.GitProvider][]string{
3441
codersdk.GitProviderAzureDevops: {"vso.code_write"},
35-
codersdk.GitProviderBitBucket: {"repository:write"},
42+
codersdk.GitProviderBitBucket: {"account", "repository:write"},
3643
codersdk.GitProviderGitLab: {"write_repository"},
3744
codersdk.GitProviderGitHub: {"repo"},
3845
}

coderd/workspaceagents.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"errors"
88
"fmt"
9+
"io"
910
"net"
1011
"net/http"
1112
"net/netip"
@@ -1159,6 +1160,19 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
11591160
if gitAuthLink.OAuthExpiry.Before(database.Now()) {
11601161
continue
11611162
}
1163+
if gitAuthConfig.ValidateURL != "" {
1164+
valid, err := validateGitToken(ctx, gitAuthConfig.ValidateURL, gitAuthLink.OAuthAccessToken)
1165+
if err != nil {
1166+
api.Logger.Warn(ctx, "failed to validate git auth token",
1167+
slog.F("workspace_owner_id", workspace.OwnerID.String()),
1168+
slog.F("validate_url", gitAuthConfig.ValidateURL),
1169+
slog.Error(err),
1170+
)
1171+
}
1172+
if !valid {
1173+
continue
1174+
}
1175+
}
11621176
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(gitAuthConfig.Type, gitAuthLink.OAuthAccessToken))
11631177
return
11641178
}
@@ -1214,6 +1228,24 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
12141228
return
12151229
}
12161230

1231+
if gitAuthConfig.ValidateURL != "" {
1232+
valid, err := validateGitToken(ctx, gitAuthConfig.ValidateURL, token.AccessToken)
1233+
if err != nil {
1234+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1235+
Message: "Failed to validate Git authentication token.",
1236+
Detail: err.Error(),
1237+
})
1238+
return
1239+
}
1240+
if !valid {
1241+
// The token is no longer valid!
1242+
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentGitAuthResponse{
1243+
URL: redirectURL.String(),
1244+
})
1245+
return
1246+
}
1247+
}
1248+
12171249
if token.AccessToken != gitAuthLink.OAuthAccessToken {
12181250
// Update it
12191251
err = api.Database.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{
@@ -1235,6 +1267,30 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
12351267
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(gitAuthConfig.Type, token.AccessToken))
12361268
}
12371269

1270+
// validateGitToken ensures the git token provided is valid
1271+
// against the provided URL.
1272+
func validateGitToken(ctx context.Context, validateURL, token string) (bool, error) {
1273+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, validateURL, nil)
1274+
if err != nil {
1275+
return false, err
1276+
}
1277+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
1278+
res, err := http.DefaultClient.Do(req)
1279+
if err != nil {
1280+
return false, err
1281+
}
1282+
defer res.Body.Close()
1283+
if res.StatusCode == http.StatusUnauthorized {
1284+
// The token is no longer valid!
1285+
return false, nil
1286+
}
1287+
if res.StatusCode != http.StatusOK {
1288+
data, _ := io.ReadAll(res.Body)
1289+
return false, xerrors.Errorf("git token validation failed: status %d: body: %s", res.StatusCode, data)
1290+
}
1291+
return true, nil
1292+
}
1293+
12381294
// Provider types have different username/password formats.
12391295
func formatGitAuthAccessToken(typ codersdk.GitProvider, token string) codersdk.WorkspaceAgentGitAuthResponse {
12401296
var resp codersdk.WorkspaceAgentGitAuthResponse

coderd/workspaceagents_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"net"
99
"net/http"
10+
"net/http/httptest"
1011
"regexp"
1112
"runtime"
1213
"strconv"
@@ -934,6 +935,77 @@ func TestWorkspaceAgentsGitAuth(t *testing.T) {
934935
resp = gitAuthCallback(t, "github", client)
935936
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
936937
})
938+
t.Run("ValidateURL", func(t *testing.T) {
939+
t.Parallel()
940+
ctx, cancelFunc := testutil.Context(t)
941+
defer cancelFunc()
942+
943+
srv := httptest.NewServer(nil)
944+
defer srv.Close()
945+
client := coderdtest.New(t, &coderdtest.Options{
946+
IncludeProvisionerDaemon: true,
947+
GitAuthConfigs: []*gitauth.Config{{
948+
ValidateURL: srv.URL,
949+
OAuth2Config: &oauth2Config{},
950+
ID: "github",
951+
Regex: regexp.MustCompile(`github\.com`),
952+
Type: codersdk.GitProviderGitHub,
953+
}},
954+
})
955+
user := coderdtest.CreateFirstUser(t, client)
956+
authToken := uuid.NewString()
957+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
958+
Parse: echo.ParseComplete,
959+
ProvisionPlan: echo.ProvisionComplete,
960+
ProvisionApply: []*proto.Provision_Response{{
961+
Type: &proto.Provision_Response_Complete{
962+
Complete: &proto.Provision_Complete{
963+
Resources: []*proto.Resource{{
964+
Name: "example",
965+
Type: "aws_instance",
966+
Agents: []*proto.Agent{{
967+
Id: uuid.NewString(),
968+
Auth: &proto.Agent_Token{
969+
Token: authToken,
970+
},
971+
}},
972+
}},
973+
},
974+
},
975+
}},
976+
})
977+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
978+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
979+
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
980+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
981+
982+
agentClient := codersdk.New(client.URL)
983+
agentClient.SetSessionToken(authToken)
984+
985+
resp := gitAuthCallback(t, "github", client)
986+
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
987+
988+
// If the validation URL says unauthorized, the callback
989+
// URL to re-authenticate should be returned.
990+
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
991+
w.WriteHeader(http.StatusUnauthorized)
992+
})
993+
res, err := agentClient.WorkspaceAgentGitAuth(ctx, "github.com/asd/asd", false)
994+
require.NoError(t, err)
995+
require.NotEmpty(t, res.URL)
996+
997+
// If the validation URL gives a non-OK status code, this
998+
// should be treated as an internal server error.
999+
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1000+
w.WriteHeader(http.StatusForbidden)
1001+
w.Write([]byte("Something went wrong!"))
1002+
})
1003+
_, err = agentClient.WorkspaceAgentGitAuth(ctx, "github.com/asd/asd", false)
1004+
var apiError *codersdk.Error
1005+
require.ErrorAs(t, err, &apiError)
1006+
require.Equal(t, http.StatusInternalServerError, apiError.StatusCode())
1007+
require.Equal(t, "git token validation failed: status 403: body: Something went wrong!", apiError.Detail)
1008+
})
9371009

9381010
t.Run("ExpiredNoRefresh", func(t *testing.T) {
9391011
t.Parallel()

codersdk/deploymentconfig.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ type GitAuthConfig struct {
126126
ClientSecret string `json:"-" yaml:"client_secret"`
127127
AuthURL string `json:"auth_url"`
128128
TokenURL string `json:"token_url"`
129+
ValidateURL string `json:"validate_url"`
129130
Regex string `json:"regex"`
130131
NoRefresh bool `json:"no_refresh"`
131132
Scopes []string `json:"scopes"`

site/src/api/typesGenerated.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ export interface GitAuthConfig {
355355
readonly client_id: string
356356
readonly auth_url: string
357357
readonly token_url: string
358+
readonly validate_url: string
358359
readonly regex: string
359360
readonly no_refresh: boolean
360361
readonly scopes: string[]

0 commit comments

Comments
 (0)