Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/deployment/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,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",
Expand All @@ -186,6 +187,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,
Expand Down
2 changes: 1 addition & 1 deletion cli/gitaskpass.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
8 changes: 8 additions & 0 deletions coderd/gitauth/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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!
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion coderd/gitauth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}
Expand Down
49 changes: 49 additions & 0 deletions coderd/workspaceagents.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
Expand Down Expand Up @@ -1158,6 +1159,12 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
if gitAuthLink.OAuthExpiry.Before(database.Now()) {
continue
}
if gitAuthConfig.ValidateURL != "" {
valid, _ := validateGitToken(ctx, gitAuthConfig.ValidateURL, gitAuthLink.OAuthAccessToken)
if !valid {
continue
}
}
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(gitAuthConfig.Type, gitAuthLink.OAuthAccessToken))
return
}
Expand Down Expand Up @@ -1213,6 +1220,24 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
return
}

if gitAuthConfig.ValidateURL != "" {
valid, err := validateGitToken(r.Context(), gitAuthConfig.ValidateURL, token.AccessToken)
if err != nil {
httpapi.Write(r.Context(), 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{
Expand All @@ -1234,6 +1259,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("body: %s", data)
}
return true, nil
}

// Provider types have different username/password formats.
func formatGitAuthAccessToken(typ codersdk.GitProvider, token string) codersdk.WorkspaceAgentGitAuthResponse {
var resp codersdk.WorkspaceAgentGitAuthResponse
Expand Down
69 changes: 69 additions & 0 deletions coderd/workspaceagents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"regexp"
"runtime"
"strconv"
Expand Down Expand Up @@ -934,6 +935,74 @@ 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()
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(context.Background(), "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(context.Background(), "github.com/asd/asd", false)
var apiError *codersdk.Error
require.ErrorAs(t, err, &apiError)
require.Equal(t, http.StatusInternalServerError, apiError.StatusCode())
require.Equal(t, "Something went wrong!", apiError.Detail)
})

t.Run("ExpiredNoRefresh", func(t *testing.T) {
t.Parallel()
Expand Down
1 change: 1 addition & 0 deletions codersdk/deploymentconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,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"`
Expand Down
1 change: 1 addition & 0 deletions site/src/api/typesGenerated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down