Skip to content

Commit e2d8f47

Browse files
authored
chore: Refactor base url logic (#2951)
Signed-off-by: Steve Hipwell <steve.hipwell@gmail.com>
1 parent b92e4f9 commit e2d8f47

File tree

7 files changed

+183
-71
lines changed

7 files changed

+183
-71
lines changed

github/apps.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"fmt"
99
"io"
1010
"net/http"
11+
"net/url"
12+
"path"
1113
"time"
1214

1315
"github.com/go-jose/go-jose/v3"
@@ -16,7 +18,7 @@ import (
1618

1719
// GenerateOAuthTokenFromApp generates a GitHub OAuth access token from a set of valid GitHub App credentials.
1820
// The returned token can be used to interact with both GitHub's REST and GraphQL APIs.
19-
func GenerateOAuthTokenFromApp(baseURL, appID, appInstallationID, pemData string) (string, error) {
21+
func GenerateOAuthTokenFromApp(baseURL *url.URL, appID, appInstallationID, pemData string) (string, error) {
2022
appJWT, err := generateAppJWT(appID, time.Now(), []byte(pemData))
2123
if err != nil {
2224
return "", err
@@ -30,14 +32,15 @@ func GenerateOAuthTokenFromApp(baseURL, appID, appInstallationID, pemData string
3032
return token, nil
3133
}
3234

33-
func getInstallationAccessToken(baseURL, jwt, installationID string) (string, error) {
34-
if baseURL != "https://api.github.com/" && !GHECDataResidencyMatch.MatchString(baseURL) {
35-
baseURL += "api/v3/"
35+
func getInstallationAccessToken(baseURL *url.URL, jwt, installationID string) (string, error) {
36+
hostname := baseURL.Hostname()
37+
if hostname != DotComHost && !GHECDataResidencyHostMatch.MatchString(hostname) {
38+
baseURL.Path = path.Join(baseURL.Path, "api/v3/")
3639
}
3740

38-
url := fmt.Sprintf("%sapp/installations/%s/access_tokens", baseURL, installationID)
41+
baseURL.Path = path.Join(baseURL.Path, "app/installations/", installationID, "access_tokens")
3942

40-
req, err := http.NewRequest(http.MethodPost, url, nil)
43+
req, err := http.NewRequest(http.MethodPost, baseURL.String(), nil)
4144
if err != nil {
4245
return "", err
4346
}

github/apps_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"crypto/x509"
66
"encoding/pem"
77
"fmt"
8+
"net/url"
89
"os"
910
"strings"
1011
"testing"
@@ -157,7 +158,12 @@ func TestGetInstallationAccessToken(t *testing.T) {
157158
})
158159
defer ts.Close()
159160

160-
accessToken, err := getInstallationAccessToken(ts.URL+"/", fakeJWT, testGitHubAppInstallationID)
161+
u, err := url.Parse(ts.URL)
162+
if err != nil {
163+
t.Fatalf("could not parse test server url")
164+
}
165+
166+
accessToken, err := getInstallationAccessToken(u, fakeJWT, testGitHubAppInstallationID)
161167
if err != nil {
162168
t.Logf("Unexpected error: %s", err)
163169
t.Fail()

github/config.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,12 @@ type Owner struct {
3737
IsOrganization bool
3838
}
3939

40-
// GHECDataResidencyMatch is a regex to match a GitHub Enterprise Cloud data residency URL:
41-
// https://[hostname].ghe.com instances expect paths that behave similar to GitHub.com, not GitHub Enterprise Server.
42-
var GHECDataResidencyMatch = regexp.MustCompile(`^https:\/\/[a-zA-Z0-9.\-]*\.ghe\.com$`)
40+
// DotComHost is the hostname for GitHub.com API.
41+
const DotComHost = "api.github.com"
42+
43+
// GHECDataResidencyHostMatch is a regex to match a GitHub Enterprise Cloud data residency host:
44+
// https://[hostname].ghe.com/ instances expect paths that behave similar to GitHub.com, not GitHub Enterprise Server.
45+
var GHECDataResidencyHostMatch = regexp.MustCompile(`^[a-zA-Z0-9.\-]+\.ghe\.com\/?$`)
4346

4447
func RateLimitedHTTPClient(client *http.Client, writeDelay, readDelay, retryDelay time.Duration, parallelRequests bool, retryableErrors map[int]bool, maxRetries int) *http.Client {
4548
client.Transport = NewEtagTransport(client.Transport)
@@ -82,7 +85,8 @@ func (c *Config) NewGraphQLClient(client *http.Client) (*githubv4.Client, error)
8285
return nil, err
8386
}
8487

85-
if uv4.String() != "https://api.github.com/" && !GHECDataResidencyMatch.MatchString(uv4.String()) {
88+
hostname := uv4.Hostname()
89+
if hostname != DotComHost && !GHECDataResidencyHostMatch.MatchString(hostname) {
8690
uv4.Path = path.Join(uv4.Path, "api/graphql/")
8791
} else {
8892
uv4.Path = path.Join(uv4.Path, "graphql")
@@ -97,8 +101,9 @@ func (c *Config) NewRESTClient(client *http.Client) (*github.Client, error) {
97101
return nil, err
98102
}
99103

100-
if uv3.String() != "https://api.github.com/" && !GHECDataResidencyMatch.MatchString(uv3.String()) {
101-
uv3.Path = uv3.Path + "api/v3/"
104+
hostname := uv3.Hostname()
105+
if hostname != DotComHost && !GHECDataResidencyHostMatch.MatchString(hostname) {
106+
uv3.Path = path.Join(uv3.Path, "api/v3/")
102107
}
103108

104109
v3client, err := github.NewClient(client).WithEnterpriseURLs(uv3.String(), "")

github/config_test.go

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,62 +2,62 @@ package github
22

33
import (
44
"context"
5+
"net/url"
56
"testing"
67

78
"github.com/shurcooL/githubv4"
89
)
910

10-
func TestGHECDataResidencyMatch(t *testing.T) {
11+
func TestGHECDataResidencyHostMatch(t *testing.T) {
1112
testCases := []struct {
1213
url string
1314
matches bool
1415
description string
1516
}{
1617
{
17-
url: "https://customer.ghe.com",
18+
url: "https://customer.ghe.com/",
1819
matches: true,
1920
description: "GHEC data residency URL with customer name",
2021
},
2122
{
22-
url: "https://customer-name.ghe.com",
23+
url: "https://customer-name.ghe.com/",
2324
matches: true,
2425
description: "GHEC data residency URL with hyphenated name",
2526
},
2627
{
27-
url: "https://api.github.com",
28-
matches: false,
29-
description: "GitHub.com API URL",
30-
},
31-
{
32-
url: "https://github.com",
33-
matches: false,
34-
description: "GitHub.com URL",
28+
url: "https://customer.ghe.com",
29+
matches: true,
30+
description: "GHEC data residency URL without a trailing slash",
3531
},
3632
{
37-
url: "https://example.com",
33+
url: "https://ghe.com/",
3834
matches: false,
39-
description: "Generic URL",
35+
description: "GHEC domain without subdomain",
4036
},
4137
{
42-
url: "http://customer.ghe.com",
38+
url: "https://github.com/",
4339
matches: false,
44-
description: "Non-HTTPS GHEC URL",
40+
description: "GitHub.com URL",
4541
},
4642
{
47-
url: "https://customer.ghe.com/api/v3",
43+
url: "https://api.github.com/",
4844
matches: false,
49-
description: "GHEC URL with path",
45+
description: "GitHub.com API URL",
5046
},
5147
{
52-
url: "https://ghe.com",
48+
url: "https://example.com/",
5349
matches: false,
54-
description: "GHEC domain without subdomain",
50+
description: "Generic URL",
5551
},
5652
}
5753

5854
for _, tc := range testCases {
5955
t.Run(tc.description, func(t *testing.T) {
60-
matches := GHECDataResidencyMatch.MatchString(tc.url)
56+
u, err := url.Parse(tc.url)
57+
if err != nil {
58+
t.Fatalf("failed to parse URL %q: %s", tc.url, err)
59+
}
60+
matches := GHECDataResidencyHostMatch.MatchString(u.Hostname())
6161
if matches != tc.matches {
6262
t.Errorf("URL %q: expected match=%v, got %v", tc.url, tc.matches, matches)
6363
}

github/data_source_github_app_token.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func dataSourceGithubAppTokenRead(d *schema.ResourceData, meta any) error {
4141
installationID := d.Get("installation_id").(string)
4242
pemFile := d.Get("pem_file").(string)
4343

44-
baseURL := meta.(*Owner).v3client.BaseURL.String()
44+
baseURL := meta.(*Owner).v3client.BaseURL
4545

4646
// The Go encoding/pem package only decodes PEM formatted blocks
4747
// that contain new lines. Some platforms, like Terraform Cloud,

github/provider.go

Lines changed: 40 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"net/url"
88
"os"
99
"os/exec"
10-
"regexp"
1110
"strings"
1211
"time"
1312

@@ -367,6 +366,11 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc {
367366
owner = org
368367
}
369368

369+
bu, err := validateBaseURL(baseURL)
370+
if err != nil {
371+
return nil, diag.FromErr(err)
372+
}
373+
370374
if appAuth, ok := d.Get("app_auth").([]any); ok && len(appAuth) > 0 && appAuth[0] != nil {
371375
appAuthAttr := appAuth[0].(map[string]any)
372376

@@ -397,25 +401,16 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc {
397401
return nil, wrapErrors([]error{fmt.Errorf("app_auth.pem_file must be set and contain a non-empty value")})
398402
}
399403

400-
appToken, err := GenerateOAuthTokenFromApp(baseURL, appID, appInstallationID, appPemFile)
404+
appToken, err := GenerateOAuthTokenFromApp(bu, appID, appInstallationID, appPemFile)
401405
if err != nil {
402406
return nil, wrapErrors([]error{err})
403407
}
404408

405409
token = appToken
406410
}
407411

408-
isGithubDotCom, err := regexp.MatchString("^"+regexp.QuoteMeta("https://api.github.com"), baseURL)
409-
if err != nil {
410-
return nil, diag.FromErr(err)
411-
}
412-
413412
if token == "" {
414-
ghAuthToken, err := tokenFromGhCli(baseURL, isGithubDotCom)
415-
if err != nil {
416-
return nil, diag.FromErr(fmt.Errorf("gh auth token: %w", err))
417-
}
418-
token = ghAuthToken
413+
token = tokenFromGHCLI(bu)
419414
}
420415

421416
writeDelay := d.Get("write_delay_ms").(int)
@@ -488,41 +483,49 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc {
488483
}
489484
}
490485

486+
// validateBaseURL checks that the provided base URL is valid and can be used.
487+
func validateBaseURL(b string) (*url.URL, error) {
488+
u, err := url.Parse(b)
489+
if err != nil {
490+
return nil, err
491+
}
492+
493+
if !u.IsAbs() {
494+
return nil, fmt.Errorf("base_url must be absolute")
495+
}
496+
497+
hostname := u.Hostname()
498+
if hostname == DotComHost || GHECDataResidencyHostMatch.MatchString(hostname) {
499+
if u.Scheme != "https" {
500+
return nil, fmt.Errorf("base_url for github.com or ghe.com must use the https scheme")
501+
}
502+
if len(u.Path) > 1 {
503+
return nil, fmt.Errorf("base_url for github.com or ghe.com must not contain a path, got %s", u.Path)
504+
}
505+
}
506+
507+
return u, err
508+
}
509+
491510
// See https://github.com/integrations/terraform-provider-github/issues/1822
492-
func tokenFromGhCli(baseURL string, isGithubDotCom bool) (string, error) {
511+
func tokenFromGHCLI(u *url.URL) string {
493512
ghCliPath := os.Getenv("GH_PATH")
494513
if ghCliPath == "" {
495514
ghCliPath = "gh"
496515
}
497-
hostname := ""
498-
if isGithubDotCom {
499-
hostname = "github.com"
500-
} else {
501-
parsedURL, err := url.Parse(baseURL)
502-
if err != nil {
503-
return "", fmt.Errorf("parse %s: %w", baseURL, err)
504-
}
505-
hostname = parsedURL.Host
516+
517+
host := u.Host
518+
if u.Hostname() == DotComHost {
519+
host = "github.com"
506520
}
507-
// GitHub CLI uses different base URLs in ~/.config/gh/hosts.yml, so when
508-
// we're using the standard base path of this provider, it doesn't align
509-
// with the way `gh` CLI stores the credentials. The following doesn't work:
510-
//
511-
// $ gh auth token --hostname api.github.com
512-
// > no oauth token
513-
//
514-
// ... but the following does work correctly
515-
//
516-
// $ gh auth token --hostname github.com
517-
// > gh..<valid token>
518-
hostname = strings.TrimPrefix(hostname, "api.")
519-
out, err := exec.Command(ghCliPath, "auth", "token", "--hostname", hostname).Output()
521+
522+
out, err := exec.Command(ghCliPath, "auth", "token", "--hostname", host).Output()
520523
if err != nil {
521524
// GH CLI is either not installed or there was no `gh auth login` command issued,
522525
// which is fine. don't return the error to keep the flow going
523-
return "", nil //nolint:nilerr
526+
return ""
524527
}
525528

526529
log.Printf("[INFO] Using the token from GitHub CLI")
527-
return strings.TrimSpace(string(out)), nil
530+
return strings.TrimSpace(string(out))
528531
}

0 commit comments

Comments
 (0)