Skip to content

Commit b291c81

Browse files
committed
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.
1 parent 71bc48d commit b291c81

File tree

8 files changed

+138
-2
lines changed

8 files changed

+138
-2
lines changed

cli/deployment/config_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ func TestConfig(t *testing.T) {
165165
"CODER_GITAUTH_0_CLIENT_SECRET": "secret",
166166
"CODER_GITAUTH_0_AUTH_URL": "https://auth.com",
167167
"CODER_GITAUTH_0_TOKEN_URL": "https://token.com",
168+
"CODER_GITAUTH_0_VALIDATE_URL": "https://validate.com",
168169
"CODER_GITAUTH_0_REGEX": "github.com",
169170
"CODER_GITAUTH_0_SCOPES": "read write",
170171
"CODER_GITAUTH_0_NO_REFRESH": "true",
@@ -186,6 +187,7 @@ func TestConfig(t *testing.T) {
186187
ClientSecret: "secret",
187188
AuthURL: "https://auth.com",
188189
TokenURL: "https://token.com",
190+
ValidateURL: "https://validate.com",
189191
Regex: "github.com",
190192
Scopes: []string{"read", "write"},
191193
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: 48 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"
@@ -1158,6 +1159,12 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
11581159
if gitAuthLink.OAuthExpiry.Before(database.Now()) {
11591160
continue
11601161
}
1162+
if gitAuthConfig.ValidateURL != "" {
1163+
valid, _ := validateGitToken(ctx, gitAuthConfig.ValidateURL, gitAuthLink.OAuthAccessToken)
1164+
if !valid {
1165+
continue
1166+
}
1167+
}
11611168
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(gitAuthConfig.Type, gitAuthLink.OAuthAccessToken))
11621169
return
11631170
}
@@ -1213,6 +1220,23 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
12131220
return
12141221
}
12151222

1223+
if gitAuthConfig.ValidateURL != "" {
1224+
valid, err := validateGitToken(r.Context(), gitAuthConfig.ValidateURL, token.AccessToken)
1225+
if err != nil {
1226+
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
1227+
Message: "Failed to validate Git authentication token.",
1228+
Detail: err.Error(),
1229+
})
1230+
return
1231+
}
1232+
if !valid {
1233+
// The token is no longer valid!
1234+
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentGitAuthResponse{
1235+
URL: redirectURL.String(),
1236+
})
1237+
}
1238+
}
1239+
12161240
if token.AccessToken != gitAuthLink.OAuthAccessToken {
12171241
// Update it
12181242
err = api.Database.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{
@@ -1234,6 +1258,30 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
12341258
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(gitAuthConfig.Type, token.AccessToken))
12351259
}
12361260

1261+
// validateGitToken ensures the git token provided is valid
1262+
// against the provided URL.
1263+
func validateGitToken(ctx context.Context, validateURL, token string) (bool, error) {
1264+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, validateURL, nil)
1265+
if err != nil {
1266+
return false, err
1267+
}
1268+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
1269+
res, err := http.DefaultClient.Do(req)
1270+
if err != nil {
1271+
return false, err
1272+
}
1273+
defer res.Body.Close()
1274+
if res.StatusCode == http.StatusUnauthorized {
1275+
// The token is no longer valid!
1276+
return false, nil
1277+
}
1278+
if res.StatusCode != http.StatusOK {
1279+
data, _ := io.ReadAll(res.Body)
1280+
return false, xerrors.Errorf("body: %s", data)
1281+
}
1282+
return true, nil
1283+
}
1284+
12371285
// Provider types have different username/password formats.
12381286
func formatGitAuthAccessToken(typ codersdk.GitProvider, token string) codersdk.WorkspaceAgentGitAuthResponse {
12391287
var resp codersdk.WorkspaceAgentGitAuthResponse

coderd/workspaceagents_test.go

Lines changed: 69 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,74 @@ 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+
srv := httptest.NewServer(nil)
941+
defer srv.Close()
942+
client := coderdtest.New(t, &coderdtest.Options{
943+
IncludeProvisionerDaemon: true,
944+
GitAuthConfigs: []*gitauth.Config{{
945+
ValidateURL: srv.URL,
946+
OAuth2Config: &oauth2Config{},
947+
ID: "github",
948+
Regex: regexp.MustCompile(`github\.com`),
949+
Type: codersdk.GitProviderGitHub,
950+
}},
951+
})
952+
user := coderdtest.CreateFirstUser(t, client)
953+
authToken := uuid.NewString()
954+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
955+
Parse: echo.ParseComplete,
956+
ProvisionPlan: echo.ProvisionComplete,
957+
ProvisionApply: []*proto.Provision_Response{{
958+
Type: &proto.Provision_Response_Complete{
959+
Complete: &proto.Provision_Complete{
960+
Resources: []*proto.Resource{{
961+
Name: "example",
962+
Type: "aws_instance",
963+
Agents: []*proto.Agent{{
964+
Id: uuid.NewString(),
965+
Auth: &proto.Agent_Token{
966+
Token: authToken,
967+
},
968+
}},
969+
}},
970+
},
971+
},
972+
}},
973+
})
974+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
975+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
976+
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
977+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
978+
979+
agentClient := codersdk.New(client.URL)
980+
agentClient.SetSessionToken(authToken)
981+
982+
resp := gitAuthCallback(t, "github", client)
983+
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
984+
985+
// If the validation URL says unauthorized, the callback
986+
// URL to re-authenticate should be returned.
987+
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
988+
w.WriteHeader(http.StatusUnauthorized)
989+
})
990+
res, err := agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", false)
991+
require.NoError(t, err)
992+
require.NotEmpty(t, res.URL)
993+
994+
// If the validation URL gives a non-OK status code, this
995+
// should be treated as an internal server error.
996+
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
997+
w.WriteHeader(http.StatusForbidden)
998+
w.Write([]byte("Something went wrong!"))
999+
})
1000+
_, err = agentClient.WorkspaceAgentGitAuth(context.Background(), "github.com/asd/asd", false)
1001+
var apiError *codersdk.Error
1002+
require.ErrorAs(t, err, &apiError)
1003+
require.Equal(t, http.StatusInternalServerError, apiError.StatusCode())
1004+
require.Equal(t, "Something went wrong!", apiError.Detail)
1005+
})
9371006

9381007
t.Run("ExpiredNoRefresh", func(t *testing.T) {
9391008
t.Parallel()

codersdk/deploymentconfig.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ type GitAuthConfig struct {
125125
ClientSecret string `json:"-" yaml:"client_secret"`
126126
AuthURL string `json:"auth_url"`
127127
TokenURL string `json:"token_url"`
128+
ValidateURL string `json:"validate_url"`
128129
Regex string `json:"regex"`
129130
NoRefresh bool `json:"no_refresh"`
130131
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)