Repository: nicklaw5/helix (go)

Issue:
Auto Refreshing Access Tokens
I got my application to work with refreshing tokens on a 401 like twitch suggests in https://dev.twitch.tv/docs/authentication/refresh-tokens/. I originally tried making it work by adding to the library, but I had no idea if it's needed or wanted, so instead I created an `HTTPClient` that I pass in instead of a token. It is similar to how the [spotify library](https://github.com/zmb3/spotify/blob/master/auth/auth.go) I use does with oauth2 tokens.

If this is helpful for you adding auto token refreshing, feel free to use it. If not feel free to close the issue.

`TokenHandler` is an wrapper of token storage (used to be database now a json file). `handler.GetTwitchToken` gets the twitch token from memory, and `handler.write` saves the file.

old NewClient method
```go
token, err := c.tokens.GetTwitchToken()
if err != nil {
	return fmt.Errorf("could not get twitch token: %w", err)
}

client, err := helix.NewClientWithContext(ctx, &helix.Options{
	ClientID: c.settings.ClientID(),
	UserAccessToken: token.AccessToken,
})
```

new NewClient method
```go
client, err := helix.NewClientWithContext(ctx, &helix.Options{
	ClientID: c.settings.ClientID(),
	HTTPClient: c.tokens.NewTwitchClient(ctx, c.settings),
})
```

```go
func (handler *TokenHandler) NewTwitchClient(ctx context.Context, settings *SettingsHandler) *TokenHTTPClient {
	client := &TokenHTTPClient{
		ctx:      ctx,
		logger:   twitchClientTokenLogger,
		settings: settings,
		save:     handler.write,
		token:    handler.GetTwitchToken,
	}

	return client
}

type TokenHTTPClient struct {
	ctx      context.Context
	logger   *slog.Logger
	settings *SettingsHandler
	save     func() error
	token    func() (*Token, error)
}

func (client *TokenHTTPClient) Do(req *http.Request) (*http.Response, error) {
	err := client.replaceAuth(req)
	if err != nil {
		return nil, fmt.Errorf("could not replace auth header: %w", err)
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}

	if resp.StatusCode == http.StatusUnauthorized {
		err := client.refreshToken(req)
		if err != nil {
			return nil, fmt.Errorf("got unauthorized and could not refresh token: %w", err)
		}

		// Don't defer the body close and do it here
		// because the caller will close the body,
		// but if we're getting a new response, then
		// this body must be closed
		resp.Body.Close()

		err = client.replaceAuth(req)
		if err != nil {
			return nil, fmt.Errorf("could not replace auth header: %w", err)
		}

		return http.DefaultClient.Do(req)
	}

	return resp, nil
}

func (client *TokenHTTPClient) replaceAuth(req *http.Request) error {
	token, err := client.token()
	if err != nil {
		return fmt.Errorf("could not get token: %w", err)
	}

	req.Header.Set("Authorization", fmt.Sprintf("%s %s", "Bearer", token.AccessToken))
	return nil
}

func (client *TokenHTTPClient) refreshToken(req *http.Request) error {
	client.logger.Info("refreshing tokens")

	refreshClient, err := helix.NewClientWithContext(client.ctx, &helix.Options{
		ClientID:     client.settings.ClientID(),
		ClientSecret: client.settings.ClientSecret(),
	})
	if err != nil {
		return fmt.Errorf("could not create client to refresh token: %w", err)
	}

	token, _ := client.token()

	isValid, validateResp, err := refreshClient.ValidateToken(token.RefreshToken)
	if err == nil && isValid {
		return nil
	}

	defer func() {
		err := client.save()
		if err != nil {
			client.logger.Error("could not save token change", err)
		}
	}()

	token.AccessToken = ""

	if err != nil {
		return fmt.Errorf("could not validate token: %w", err)
	}
	if validateResp.StatusCode != http.StatusUnauthorized {
		return fmt.Errorf("could not validate token: %s", validateResp.ErrorMessage)
	}

	resp, err := refreshClient.RefreshUserAccessToken(token.RefreshToken)
	if err != nil || resp.ErrorMessage != "" {
		token.RefreshToken = ""
		if err != nil {
			return fmt.Errorf("could not refresh token: %w", err)
		}
		return fmt.Errorf("could not refresh token: %s", resp.ErrorMessage)
	}

	token.AccessToken = resp.Data.AccessToken
	token.RefreshToken = resp.Data.RefreshToken
	token.TokenExpires = time.Now().UTC().Add(time.Second * time.Duration(resp.Data.ExpiresIn))

	return nil
}
```

Pull request description:
Add automatic user token refreshing.
Automatically refreshes user access token if refresh token is present. 

- Allow user to pass in, set, and get refresh token
- Added hook for caller being notified of when refreshes happen, and receive the new values
- Updated docs
- Added unit tests

Fixes #195.

Patch:
diff --git a/docs/README.md b/docs/README.md
index d18c197..096f4a4 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -133,6 +133,7 @@ type Options struct {
     ClientSecret    string            // Default: empty string
     AppAccessToken  string            // Default: empty string
     UserAccessToken string            // Default: empty string
+    RefreshToken    string            // Default: empty string
     UserAgent       string            // Default: empty string
     RedirectURI     string            // Default: empty string
     HTTPClient      HTTPClient        // Default: http.DefaultClient
@@ -281,6 +282,37 @@ Note that any subsequent API requests will utilize this same access token. So it
 token when you are finished with it. To do so, simply pass an empty string to the `SetUserAccessToken` or
 `SetAppAccessToken` methods.
 
+### Automatically refresh user access tokens
+
+If you provide a refresh token to the client, either one generated manually or via the below method, the client will automatically refresh the user access token when needed.
+
+Note that client ID, client secret, an existing user access token, and a refresh token must all be present for automatic refreshes to occur.
+
+You can provide a refresh token in the following manner:
+
+```go
+client, err := helix.NewClient(&helix.Options{
+    ClientID:        "your-client-id",
+    ClientSecret:    "your-client-secret",
+    UserAccessToken: "your-access-token",
+    RefreshToken:    "your-refresh-token",
+})
+```
+
+Or, set one later:
+
+```go
+client.SetRefreshToken("your-refresh-token")
+```
+
+To be notified when refreshes take place:
+
+```go
+client.OnUserAccessTokenRefreshed(func(newAccessToken, newRefreshToken string) {
+    // ...
+})
+```
+
 ## User-Agent Header
 
 It's entirely possible that you may want to set or change the *User-Agent* header value that is sent with each
diff --git a/helix.go b/helix.go
index 3d516ae..c8d316a 100644
--- a/helix.go
+++ b/helix.go
@@ -7,6 +7,7 @@ import (
 	"errors"
 	"fmt"
 	"io/ioutil"
+	"log"
 	"net/http"
 	"reflect"
 	"strconv"
@@ -32,6 +33,9 @@ type Client struct {
 	ctx          context.Context
 	opts         *Options
 	lastResponse *Response
+	callbacks    struct {
+		onUserAccessTokenRefreshed func(newAccessToken, newRefreshToken string)
+	}
 }
 
 type Options struct {
@@ -39,6 +43,7 @@ type Options struct {
 	ClientSecret    string
 	AppAccessToken  string
 	UserAccessToken string
+	RefreshToken    string
 	UserAgent       string
 	RedirectURI     string
 	HTTPClient      HTTPClient
@@ -370,6 +375,18 @@ func (c *Client) doRequest(req *http.Request, resp *Response) error {
 				// Successful request
 				err = json.Unmarshal(bodyBytes, &resp.Data)
 			} else {
+				// A 401 means Twitch wants us to refresh our token:
+				// https://dev.twitch.tv/docs/authentication/refresh-tokens/
+				if resp.StatusCode == http.StatusUnauthorized && c.canRefreshToken() {
+					if refreshErr := c.refreshToken(); refreshErr != nil {
+						log.Printf("Failed to refresh helix auth token: %v", refreshErr)
+						return err
+					}
+					// Try again now that we have a new token
+					c.setRequestHeaders(req)
+					continue
+				}
+
 				// Failed request
 				err = json.Unmarshal(bodyBytes, &resp)
 			}
@@ -400,6 +417,31 @@ func (c *Client) doRequest(req *http.Request, resp *Response) error {
 	return nil
 }
 
+func (c *Client) canRefreshToken() bool {
+	return c.opts.ClientID != "" &&
+		c.opts.ClientSecret != "" &&
+		c.opts.UserAccessToken != "" &&
+		c.opts.RefreshToken != ""
+}
+
+func (c *Client) refreshToken() error {
+	resp, err := c.RefreshUserAccessToken(c.opts.RefreshToken)
+	if err != nil || resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to refresh token: (%d: %s) %v", resp.StatusCode, resp.ErrorMessage, err)
+	}
+
+	c.mu.Lock()
+	c.opts.UserAccessToken = resp.Data.AccessToken
+	c.opts.RefreshToken = resp.Data.RefreshToken
+	c.mu.Unlock()
+
+	if cb := c.callbacks.onUserAccessTokenRefreshed; cb != nil {
+		go cb(resp.Data.AccessToken, resp.Data.RefreshToken)
+	}
+
+	return nil
+}
+
 func (c *Client) setRequestHeaders(req *http.Request) {
 	opts := c.opts
 
@@ -459,6 +501,17 @@ func (c *Client) SetUserAccessToken(accessToken string) {
 	c.opts.UserAccessToken = accessToken
 }
 
+// GetRefreshToken returns the current refresh token.
+func (c *Client) GetRefreshToken() string {
+	return c.opts.RefreshToken
+}
+
+func (c *Client) SetRefreshToken(refreshToken string) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	c.opts.RefreshToken = refreshToken
+}
+
 // GetAppAccessToken returns the current app access token.
 func (c *Client) GetExtensionSignedJWTToken() string {
 	return c.opts.ExtensionOpts.SignedJWTToken
@@ -481,3 +534,9 @@ func (c *Client) SetRedirectURI(uri string) {
 	defer c.mu.Unlock()
 	c.opts.RedirectURI = uri
 }
+
+func (c *Client) OnUserAccessTokenRefreshed(f func(newAccessToken, newRefreshToken string)) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	c.callbacks.onUserAccessTokenRefreshed = f
+}


Test patch:
diff --git a/helix_test.go b/helix_test.go
index 41c7b1b..5f18ffb 100644
--- a/helix_test.go
+++ b/helix_test.go
@@ -3,10 +3,12 @@ package helix
 import (
 	"context"
 	"errors"
+	"log"
 	"net/http"
 	"net/http/httptest"
 	"reflect"
 	"strconv"
+	"strings"
 	"testing"
 	"time"
 )
@@ -63,6 +65,7 @@ func TestNewClient(t *testing.T) {
 				HTTPClient:      &http.Client{},
 				AppAccessToken:  "my-app-access-token",
 				UserAccessToken: "my-user-access-token",
+				RefreshToken:    "my-refresh-token",
 				UserAgent:       "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.162 Safari/537.36",
 				RateLimitFunc:   func(*Response) error { return nil },
 				RedirectURI:     "http://localhost/auth/callback",
@@ -111,6 +114,10 @@ func TestNewClient(t *testing.T) {
 			t.Errorf("expected accessToken to be \"%s\", got \"%s\"", testCase.options.UserAccessToken, opts.UserAccessToken)
 		}
 
+		if opts.RefreshToken != testCase.options.RefreshToken {
+			t.Errorf("expected refreshToken to be \"%s\", got \"%s\"", testCase.options.RefreshToken, opts.RefreshToken)
+		}
+
 		if opts.RedirectURI != testCase.options.RedirectURI {
 			t.Errorf("expected redirectURI to be \"%s\", got \"%s\"", testCase.options.RedirectURI, opts.RedirectURI)
 		}
@@ -334,6 +341,45 @@ func TestRatelimitCallbackFailsOnError(t *testing.T) {
 	}
 }
 
+func TestAutomaticUserTokenRefresh(t *testing.T) {
+	t.Parallel()
+
+	options := &Options{
+		ClientID:        "client-id",
+		ClientSecret:    "old-client-secret",
+		UserAccessToken: "old-user-token",
+		RefreshToken:    "old-refresh-token",
+	}
+	client := newMockClient(options, func(w http.ResponseWriter, r *http.Request) {
+		if strings.Contains(r.URL.Path, "/oauth2/token") {
+			w.Write([]byte(`{"access_token":"new-access-token","expires_in":14154,"refresh_token":"new-refresh-token","scope":["analytics:read:games","bits:read","clips:edit","user:edit","user:read:email"]}`))
+		} else if strings.Contains(r.URL.Path, "/channels/followers") {
+			if strings.Contains(r.Header.Get("Authorization"), "old-user-token") {
+				w.WriteHeader(http.StatusUnauthorized)
+				w.Write([]byte(`{"error":"Unauthorized","status":401,"message":"Invalid OAuth token"}`))
+			} else {
+				w.Write([]byte(`{"total":8,"data":[],"pagination":{}}`))
+			}
+		} else {
+			log.Printf("Unknown URL sent to test server: %s", r.URL.Path)
+		}
+	})
+
+	_, err := client.GetChannelFollows(&GetChannelFollowsParams{}) // any method works
+	if err != nil {
+		t.Fatalf("Did not expect an error, got \"%s\"", err.Error())
+	}
+
+	time.Sleep(5 * time.Millisecond)
+
+	if client.opts.UserAccessToken != "new-access-token" {
+		t.Errorf("expected UserAccessToken to be %q, got %q", "new-access-token", client.opts.UserAccessToken)
+	}
+	if client.opts.RefreshToken != "new-refresh-token" {
+		t.Errorf("expected RefreshToken to be %q, got %q", "new-refresh-token", client.opts.RefreshToken)
+	}
+}
+
 func TestSetRequestHeaders(t *testing.T) {
 	t.Parallel()
 
@@ -584,6 +630,44 @@ func TestSetUserAccessToken(t *testing.T) {
 	}
 }
 
+func TestGetRefreshToken(t *testing.T) {
+	t.Parallel()
+
+	refreshToken := "my-refresh-token"
+
+	client, err := NewClient(&Options{
+		ClientID: "cid",
+	})
+	if err != nil {
+		t.Errorf("Did not expect an error, got \"%s\"", err.Error())
+	}
+
+	client.SetRefreshToken(refreshToken)
+
+	if client.GetRefreshToken() != refreshToken {
+		t.Errorf("expected GetRefreshToken to return \"%s\", got \"%s\"", refreshToken, client.GetRefreshToken())
+	}
+}
+
+func TestSetRefreshToken(t *testing.T) {
+	t.Parallel()
+
+	refreshToken := "my-refresh-token"
+
+	client, err := NewClient(&Options{
+		ClientID: "cid",
+	})
+	if err != nil {
+		t.Errorf("Did not expect an error, got \"%s\"", err.Error())
+	}
+
+	client.SetRefreshToken(refreshToken)
+
+	if client.opts.RefreshToken != refreshToken {
+		t.Errorf("expected RefreshToken to be \"%s\", got \"%s\"", refreshToken, client.opts.RefreshToken)
+	}
+}
+
 func TestSetUserAgent(t *testing.T) {
 	t.Parallel()
 
@@ -620,6 +704,53 @@ func TestSetRedirectURI(t *testing.T) {
 	}
 }
 
+func TestOnUserAccessTokenRefreshed(t *testing.T) {
+	t.Parallel()
+
+	options := &Options{
+		ClientID:        "client-id",
+		ClientSecret:    "old-client-secret",
+		UserAccessToken: "old-user-token",
+		RefreshToken:    "old-refresh-token",
+	}
+	client := newMockClient(options, func(w http.ResponseWriter, r *http.Request) {
+		if strings.Contains(r.URL.Path, "/oauth2/token") {
+			w.Write([]byte(`{"access_token":"new-access-token","expires_in":14154,"refresh_token":"new-refresh-token","scope":["analytics:read:games","bits:read","clips:edit","user:edit","user:read:email"]}`))
+		} else if strings.Contains(r.URL.Path, "/channels/followers") {
+			if strings.Contains(r.Header.Get("Authorization"), "old-user-token") {
+				w.WriteHeader(http.StatusUnauthorized)
+				w.Write([]byte(`{"error":"Unauthorized","status":401,"message":"Invalid OAuth token"}`))
+			} else {
+				w.Write([]byte(`{"total":8,"data":[],"pagination":{}}`))
+			}
+		} else {
+			log.Printf("Unknown URL sent to test server: %s", r.URL.Path)
+		}
+	})
+
+	var newAccessToken, newRefreshToken string
+	client.OnUserAccessTokenRefreshed(func(a, r string) {
+		newAccessToken = a
+		newRefreshToken = r
+	})
+	wantNewAccessToken := "new-access-token"
+	wantNewRefreshToken := "new-refresh-token"
+
+	_, err := client.GetChannelFollows(&GetChannelFollowsParams{}) // any method works
+	if err != nil {
+		t.Fatalf("Did not expect an error, got \"%s\"", err.Error())
+	}
+
+	time.Sleep(5 * time.Millisecond)
+
+	if newAccessToken != wantNewAccessToken {
+		t.Errorf("expected newAccessToken to be %q, got %q", wantNewAccessToken, newAccessToken)
+	}
+	if newRefreshToken != wantNewRefreshToken {
+		t.Errorf("expected newRefreshToken to be %q, got %q", wantNewRefreshToken, newRefreshToken)
+	}
+}
+
 func TestHydrateRequestCommon(t *testing.T) {
 	t.Parallel()
 	var sourceResponse Response