Skip to content

Commit 3d8cba2

Browse files
authored
Merge pull request #51 from jferrl/feat/with-base-url
feat: add WithBaseURL option for custom API base URLs (#50)
2 parents aea57f2 + b96f2a2 commit 3d8cba2

7 files changed

Lines changed: 368 additions & 29 deletions

File tree

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,38 @@ func main() {
278278
}
279279
```
280280

281+
### GitHub Enterprise and Custom Base URLs
282+
283+
The installation token source talks to `https://api.github.com/` by default. Two options point it elsewhere:
284+
285+
- `WithEnterpriseURL` — for **GitHub Enterprise Server (GHES)**. The supplied URL is normalized the way GHES expects, appending `/api/v3/` when the host is not already an `api.` subdomain.
286+
- `WithBaseURL` — uses the URL **verbatim** (only adding a trailing slash when missing), closely mirroring how `go-github` lets you set a custom endpoint. No `/api/v3/` suffix is added. Use it for **GitHub Enterprise Cloud (GHEC) with data residency** (`https://api.SUBDOMAIN.ghe.com/`) or to point the client at an `httptest` server in your tests.
287+
288+
```go
289+
// GitHub Enterprise Server (GHES)
290+
installationTokenSource := githubauth.NewInstallationTokenSource(
291+
installationID,
292+
appTokenSource,
293+
githubauth.WithEnterpriseURL("https://github.example.com"),
294+
)
295+
296+
// GitHub Enterprise Cloud (GHEC) with data residency
297+
installationTokenSource = githubauth.NewInstallationTokenSource(
298+
installationID,
299+
appTokenSource,
300+
githubauth.WithBaseURL("https://api.octocorp.ghe.com"),
301+
)
302+
303+
// Point at an httptest server in tests
304+
installationTokenSource = githubauth.NewInstallationTokenSource(
305+
installationID,
306+
appTokenSource,
307+
githubauth.WithBaseURL(server.URL),
308+
)
309+
```
310+
311+
Option order does not matter — `WithBaseURL`, `WithEnterpriseURL`, `WithHTTPClient`, and `WithRetryOnThrottle` can be combined in any order. If the provided URL cannot be parsed (or a `nil` HTTP client is passed), the misconfiguration is reported by the first call to `Token()` rather than silently falling back to the public GitHub API.
312+
281313
### Proactive Token Refresh
282314

283315
`oauth2.ReuseTokenSource` only refreshes a cached token *after* its expiry has passed. A request that starts at `T-100ms` with a token expiring at `T` can arrive at GitHub with an already-expired credential and receive a 401 that the caller must manually retry. This is especially painful with short application-token windows (default 10 min, optionally lower).

auth.go

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -273,29 +273,69 @@ func WithInstallationTokenOptions(opts *InstallationTokenOptions) InstallationTo
273273
}
274274
}
275275

276-
// WithHTTPClient sets the HTTP client for the GitHub App installation token source.
276+
// WithHTTPClient sets the HTTP client used to call the GitHub API. Its transport
277+
// is wrapped so installation-token requests are authenticated with the GitHub
278+
// App JWT; any base URL configured via WithBaseURL or WithEnterpriseURL is
279+
// preserved, so these options may be combined in any order.
280+
//
281+
// A nil client is a configuration error reported by the first call to Token().
277282
func WithHTTPClient(client *http.Client) InstallationTokenSourceOpt {
278283
return func(i *installationTokenSource) {
279-
client.Transport = &oauth2.Transport{
284+
if client == nil {
285+
i.setConfigErr(errors.New("WithHTTPClient: http client must not be nil"))
286+
return
287+
}
288+
289+
// Work on a shallow copy so the caller's *http.Client is not mutated;
290+
// only the Transport is swapped to inject GitHub App authentication.
291+
authClient := *client
292+
authClient.Transport = &oauth2.Transport{
280293
Source: i.src,
281294
Base: client.Transport,
282295
}
283296

284-
i.client = newGitHubClient(client)
297+
// Swap only the underlying *http.Client so the base URL and retry
298+
// settings already configured on i.client survive, regardless of the
299+
// order options are applied in.
300+
i.client.httpClient = &authClient
285301
}
286302
}
287303

288-
// WithEnterpriseURL sets the base URL for GitHub Enterprise Server.
289-
// This option should be used after WithHTTPClient to ensure the HTTP client is properly configured.
290-
// If the provided base URL is invalid, the option is ignored and default GitHub base URL is used.
304+
// WithEnterpriseURL sets the base URL for GitHub Enterprise Server (GHES). The
305+
// URL is normalized the way GHES expects, appending the "/api/v3/" path when the
306+
// host is not already an "api." subdomain. For GitHub Enterprise Cloud or a
307+
// verbatim URL (such as an httptest server), use WithBaseURL instead.
308+
//
309+
// Option order does not matter; it may be combined with WithHTTPClient,
310+
// WithBaseURL, and WithRetryOnThrottle in any order. If the URL cannot be
311+
// parsed, the error is reported by the first call to Token() rather than
312+
// silently falling back to the public GitHub API.
291313
func WithEnterpriseURL(baseURL string) InstallationTokenSourceOpt {
292314
return func(i *installationTokenSource) {
293-
enterpriseClient, err := i.client.withEnterpriseURL(baseURL)
294-
if err != nil {
295-
return
315+
if _, err := i.client.withEnterpriseURL(baseURL); err != nil {
316+
i.setConfigErr(err)
296317
}
318+
}
319+
}
297320

298-
i.client = enterpriseClient
321+
// WithBaseURL sets the API base URL used to create installation tokens, closely
322+
// mirroring how go-github lets callers point the client at a custom endpoint.
323+
// Unlike WithEnterpriseURL, the URL is used verbatim — only a trailing slash is
324+
// appended when missing, and no "/api/v3/" suffix is added.
325+
//
326+
// Use this for:
327+
// - GitHub Enterprise Cloud with data residency (https://api.SUBDOMAIN.ghe.com/)
328+
// - pointing the client at an httptest server in tests
329+
//
330+
// Option order does not matter; it may be combined with WithHTTPClient,
331+
// WithEnterpriseURL, and WithRetryOnThrottle in any order. If the URL cannot be
332+
// parsed, the error is reported by the first call to Token() rather than
333+
// silently falling back to the public GitHub API.
334+
func WithBaseURL(baseURL string) InstallationTokenSourceOpt {
335+
return func(i *installationTokenSource) {
336+
if _, err := i.client.withBaseURL(baseURL); err != nil {
337+
i.setConfigErr(err)
338+
}
299339
}
300340
}
301341

@@ -347,6 +387,20 @@ type installationTokenSource struct {
347387
client *githubClient
348388
opts *InstallationTokenOptions
349389
skew time.Duration
390+
391+
// configErr records the first invalid-configuration error encountered while
392+
// applying options (e.g. an unparseable base URL or a nil HTTP client). It is
393+
// surfaced by Token() so misconfiguration fails loudly instead of silently
394+
// falling back to the public GitHub API.
395+
configErr error
396+
}
397+
398+
// setConfigErr records err as the source's configuration error, keeping the
399+
// first error so the reported failure is independent of option order.
400+
func (t *installationTokenSource) setConfigErr(err error) {
401+
if t.configErr == nil {
402+
t.configErr = err
403+
}
350404
}
351405

352406
// NewInstallationTokenSource creates a GitHub App installation token source.
@@ -384,6 +438,10 @@ func NewInstallationTokenSource(id int64, src oauth2.TokenSource, opts ...Instal
384438

385439
// Token generates a new GitHub App installation token for authenticating as a GitHub App installation.
386440
func (t *installationTokenSource) Token() (*oauth2.Token, error) {
441+
if t.configErr != nil {
442+
return nil, t.configErr
443+
}
444+
387445
token, err := t.client.createInstallationToken(t.ctx, t.id, t.opts)
388446
if err != nil {
389447
return nil, err

auth_test.go

Lines changed: 172 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"fmt"
1616
"io"
1717
"net/http"
18+
"net/http/httptest"
1819
"reflect"
1920
"strings"
2021
"sync"
@@ -225,8 +226,10 @@ func TestNewApplicationTokenSourceFromSigner(t *testing.T) {
225226
wantErr: true,
226227
},
227228
{
228-
name: "non-rsa signer is rejected",
229-
new: func() (oauth2.TokenSource, error) { return NewApplicationTokenSourceFromSigner(int64(42), &stubSigner{pub: edPub}) },
229+
name: "non-rsa signer is rejected",
230+
new: func() (oauth2.TokenSource, error) {
231+
return NewApplicationTokenSourceFromSigner(int64(42), &stubSigner{pub: edPub})
232+
},
230233
wantErr: true,
231234
},
232235
{
@@ -472,21 +475,179 @@ func TestWithEnterpriseURL_InvalidURL(t *testing.T) {
472475
t.Fatal(err)
473476
}
474477

475-
// Test with invalid URL - error is silently ignored in WithEnterpriseURL
478+
// An invalid URL must not silently fall back to the public GitHub API; the
479+
// error surfaces on the first Token() call instead.
476480
installationTokenSource := NewInstallationTokenSource(
477481
1,
478482
appSrc,
479483
WithEnterpriseURL("ht\ntp://invalid"),
480484
)
481485

482-
// The error is silently ignored in WithEnterpriseURL, so this should still work
483-
// but will use the default URL
484486
if installationTokenSource == nil {
485-
t.Error("Expected non-nil token source")
487+
t.Fatal("Expected non-nil token source")
488+
}
489+
490+
if _, err := installationTokenSource.Token(); err == nil {
491+
t.Error("Token() error = nil, want error for invalid enterprise URL")
492+
}
493+
}
494+
495+
func TestWithBaseURL_InvalidURL(t *testing.T) {
496+
privateKey, err := generatePrivateKey()
497+
if err != nil {
498+
t.Fatal(err)
499+
}
500+
501+
appSrc, err := NewApplicationTokenSource(int64(12345), privateKey)
502+
if err != nil {
503+
t.Fatal(err)
504+
}
505+
506+
// An invalid URL must not silently fall back to the public GitHub API; the
507+
// error surfaces on the first Token() call instead.
508+
installationTokenSource := NewInstallationTokenSource(
509+
1,
510+
appSrc,
511+
WithBaseURL("ht\ntp://invalid"),
512+
)
513+
514+
if installationTokenSource == nil {
515+
t.Fatal("Expected non-nil token source")
486516
}
487517

488-
// Test that the token source is created successfully
489-
// The error is silently ignored, so the source uses the default URL
518+
if _, err := installationTokenSource.Token(); err == nil {
519+
t.Error("Token() error = nil, want error for invalid base URL")
520+
}
521+
}
522+
523+
func TestWithBaseURL_DrivesTokenThroughServer(t *testing.T) {
524+
now := time.Now().UTC()
525+
expiration := now.Add(10 * time.Minute)
526+
527+
// A bare httptest server URL (e.g. http://127.0.0.1:PORT) is exactly the
528+
// case WithEnterpriseURL mishandles: it would append "/api/v3/" and break
529+
// the mock. WithBaseURL uses the URL verbatim, so the installation-token
530+
// POST lands on the expected path.
531+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
532+
if r.URL.Path != "/app/installations/1/access_tokens" {
533+
t.Errorf("unexpected request path = %q", r.URL.Path)
534+
w.WriteHeader(http.StatusNotFound)
535+
return
536+
}
537+
w.Header().Set("Content-Type", "application/json")
538+
_ = json.NewEncoder(w).Encode(InstallationToken{
539+
Token: "mocked-installation-token",
540+
ExpiresAt: expiration,
541+
})
542+
}))
543+
defer server.Close()
544+
545+
privateKey, err := generatePrivateKey()
546+
if err != nil {
547+
t.Fatal(err)
548+
}
549+
550+
appSrc, err := NewApplicationTokenSource(int64(34434), privateKey)
551+
if err != nil {
552+
t.Fatal(err)
553+
}
554+
555+
ts := NewInstallationTokenSource(1, appSrc, WithBaseURL(server.URL))
556+
557+
got, err := ts.Token()
558+
if err != nil {
559+
t.Fatalf("Token() error = %v", err)
560+
}
561+
562+
want := &oauth2.Token{
563+
AccessToken: "mocked-installation-token",
564+
TokenType: "Bearer",
565+
Expiry: expiration,
566+
}
567+
if !reflect.DeepEqual(got, want) {
568+
t.Errorf("Token() = %v, want %v", got, want)
569+
}
570+
}
571+
572+
// TestWithBaseURL_SurvivesHTTPClientOrder guards against the ordering footgun:
573+
// WithBaseURL is applied BEFORE WithHTTPClient, and the configured base URL must
574+
// survive the HTTP client swap. If it did not, the request would be sent to the
575+
// public GitHub API instead of the test server.
576+
func TestWithBaseURL_SurvivesHTTPClientOrder(t *testing.T) {
577+
now := time.Now().UTC()
578+
expiration := now.Add(10 * time.Minute)
579+
580+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
581+
if r.URL.Path != "/app/installations/7/access_tokens" {
582+
t.Errorf("request path = %q, want /app/installations/7/access_tokens", r.URL.Path)
583+
w.WriteHeader(http.StatusNotFound)
584+
return
585+
}
586+
w.Header().Set("Content-Type", "application/json")
587+
_ = json.NewEncoder(w).Encode(InstallationToken{Token: "ordered-token", ExpiresAt: expiration})
588+
}))
589+
defer server.Close()
590+
591+
privateKey, err := generatePrivateKey()
592+
if err != nil {
593+
t.Fatal(err)
594+
}
595+
appSrc, err := NewApplicationTokenSource(int64(1), privateKey)
596+
if err != nil {
597+
t.Fatal(err)
598+
}
599+
600+
ts := NewInstallationTokenSource(7, appSrc,
601+
WithBaseURL(server.URL),
602+
WithHTTPClient(&http.Client{}),
603+
)
604+
605+
got, err := ts.Token()
606+
if err != nil {
607+
t.Fatalf("Token() error = %v", err)
608+
}
609+
if got.AccessToken != "ordered-token" {
610+
t.Errorf("AccessToken = %q, want %q", got.AccessToken, "ordered-token")
611+
}
612+
}
613+
614+
func TestWithHTTPClient_NilClient(t *testing.T) {
615+
privateKey, err := generatePrivateKey()
616+
if err != nil {
617+
t.Fatal(err)
618+
}
619+
appSrc, err := NewApplicationTokenSource(int64(1), privateKey)
620+
if err != nil {
621+
t.Fatal(err)
622+
}
623+
624+
// A nil HTTP client must not panic; the error surfaces on Token().
625+
ts := NewInstallationTokenSource(1, appSrc, WithHTTPClient(nil))
626+
if _, err := ts.Token(); err == nil {
627+
t.Error("Token() error = nil, want error for nil http client")
628+
}
629+
}
630+
631+
func TestWithHTTPClient_DoesNotMutateCallerClient(t *testing.T) {
632+
privateKey, err := generatePrivateKey()
633+
if err != nil {
634+
t.Fatal(err)
635+
}
636+
appSrc, err := NewApplicationTokenSource(int64(1), privateKey)
637+
if err != nil {
638+
t.Fatal(err)
639+
}
640+
641+
originalTransport := http.DefaultTransport
642+
client := &http.Client{Transport: originalTransport}
643+
644+
_ = NewInstallationTokenSource(1, appSrc, WithHTTPClient(client))
645+
646+
// The option must operate on a copy; the caller's client (which may be
647+
// shared elsewhere) must keep its original transport.
648+
if client.Transport != originalTransport {
649+
t.Errorf("WithHTTPClient mutated the caller's client Transport = %T, want it unchanged", client.Transport)
650+
}
490651
}
491652

492653
func Test_installationTokenSource_Token(t *testing.T) {
@@ -1035,9 +1196,9 @@ func TestWithExpirySkew_Wiring(t *testing.T) {
10351196
}
10361197

10371198
tests := []struct {
1038-
name string
1039-
skewOpts []ApplicationTokenOpt
1040-
wantSkew bool // true → *reuseTokenSourceWithSkew, false → delegated
1199+
name string
1200+
skewOpts []ApplicationTokenOpt
1201+
wantSkew bool // true → *reuseTokenSourceWithSkew, false → delegated
10411202
}{
10421203
{
10431204
name: "default skew uses wrapper",

0 commit comments

Comments
 (0)