refactor: simplify codebase
This commit is contained in:
+29
-107
@@ -13,6 +13,7 @@ import (
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -27,7 +28,7 @@ const (
|
||||
|
||||
tvOrigin = "https://android.tv.twitch.tv"
|
||||
tvReferer = "https://android.tv.twitch.tv/"
|
||||
tvUserAgent = "Mozilla/5.0 (Linux; Android 7.1; Smart Box C1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"
|
||||
TVUserAgent = "Mozilla/5.0 (Linux; Android 7.1; Smart Box C1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
var RequiredScopes = []string{
|
||||
@@ -71,20 +72,15 @@ type deviceCodeResponse struct {
|
||||
|
||||
// tokenResponse models the token payload returned after device authorization succeeds.
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Scope []string `json:"scope"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
// validationResponse models the token introspection data returned by Twitch validate.
|
||||
type validationResponse struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Login string `json:"login"`
|
||||
Scopes []string `json:"scopes"`
|
||||
UserID string `json:"user_id"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
ClientID string `json:"client_id"`
|
||||
Login string `json:"login"`
|
||||
Scopes []string `json:"scopes"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
// oauthErrorResponse captures structured OAuth errors returned by Twitch endpoints.
|
||||
@@ -147,7 +143,7 @@ func (c *Client) ReuseAuth(ctx context.Context, path string) (*State, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
state.applyValidation(validation, c.now())
|
||||
state.Login = validation.Login
|
||||
if err := SaveState(path, state); err != nil {
|
||||
return nil, fmt.Errorf("save validated auth state: %w", err)
|
||||
}
|
||||
@@ -175,7 +171,7 @@ func (c *Client) EnsureAuth(ctx context.Context, path string, status StatusFunc)
|
||||
c.status(status, "Validating cached token from %s", path)
|
||||
validation, err := c.ValidateToken(ctx, state.AccessToken)
|
||||
if err == nil && hasAllScopes(validation.Scopes, RequiredScopes) {
|
||||
state.applyValidation(validation, c.now())
|
||||
state.Login = validation.Login
|
||||
if err := SaveState(path, state); err != nil {
|
||||
return nil, fmt.Errorf("save validated auth state: %w", err)
|
||||
}
|
||||
@@ -203,14 +199,7 @@ func (c *Client) EnsureAuth(ctx context.Context, path string, status StatusFunc)
|
||||
}
|
||||
|
||||
state.AccessToken = tokenResp.AccessToken
|
||||
state.RefreshToken = tokenResp.RefreshToken
|
||||
state.TokenType = tokenResp.TokenType
|
||||
if state.TokenType == "" {
|
||||
state.TokenType = "bearer"
|
||||
}
|
||||
state.Scopes = tokenResp.Scope
|
||||
state.ExpiresIn = tokenResp.ExpiresIn
|
||||
state.applyValidation(validation, c.now())
|
||||
state.Login = validation.Login
|
||||
|
||||
if err := SaveState(path, state); err != nil {
|
||||
return nil, fmt.Errorf("save auth state: %w", err)
|
||||
@@ -222,13 +211,13 @@ func (c *Client) EnsureAuth(ctx context.Context, path string, status StatusFunc)
|
||||
|
||||
// ValidateToken asks Twitch whether an OAuth access token is still valid.
|
||||
func (c *Client) ValidateToken(ctx context.Context, accessToken string) (*validationResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoints().Validate, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Endpoints.Validate, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "OAuth "+accessToken)
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -269,7 +258,7 @@ func (c *Client) activate(ctx context.Context, deviceID string, status StatusFun
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
deadline := c.now().Add(time.Duration(deviceResp.ExpiresIn) * time.Second)
|
||||
deadline := c.Now().Add(time.Duration(deviceResp.ExpiresIn) * time.Second)
|
||||
|
||||
c.status(status, "== Twitch activation ==")
|
||||
c.status(status, "Open page: %s", deviceResp.VerificationURI)
|
||||
@@ -286,7 +275,7 @@ func (c *Client) requestDeviceCode(ctx context.Context, deviceID string) (*devic
|
||||
form.Set("client_id", ClientID)
|
||||
form.Set("scopes", strings.Join(RequiredScopes, " "))
|
||||
|
||||
body, statusCode, err := c.postForm(ctx, c.endpoints().Device, deviceID, form)
|
||||
body, statusCode, err := c.postForm(ctx, c.Endpoints.Device, deviceID, form)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -311,13 +300,13 @@ func (c *Client) requestDeviceCode(ctx context.Context, deviceID string) (*devic
|
||||
func (c *Client) pollForToken(ctx context.Context, deviceID, deviceCode string, interval time.Duration, deadline time.Time, status StatusFunc) (*tokenResponse, error) {
|
||||
attempt := 1
|
||||
for {
|
||||
if !c.now().Before(deadline) {
|
||||
if !c.Now().Before(deadline) {
|
||||
return nil, fmt.Errorf("device code expired at %s before authorization completed", deadline.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
remaining := deadline.Sub(c.now()).Round(time.Second)
|
||||
remaining := deadline.Sub(c.Now()).Round(time.Second)
|
||||
c.status(status, "[poll %d] waiting %s before token request (%s remaining)", attempt, interval, remaining)
|
||||
if err := c.sleep(ctx, interval); err != nil {
|
||||
if err := c.Sleep(ctx, interval); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -326,7 +315,7 @@ func (c *Client) pollForToken(ctx context.Context, deviceID, deviceCode string,
|
||||
form.Set("device_code", deviceCode)
|
||||
form.Set("grant_type", pollGrantType)
|
||||
|
||||
body, statusCode, err := c.postForm(ctx, c.endpoints().Token, deviceID, form)
|
||||
body, statusCode, err := c.postForm(ctx, c.Endpoints.Token, deviceID, form)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("poll request failed on attempt %d: %w", attempt, err)
|
||||
}
|
||||
@@ -368,12 +357,18 @@ func (c *Client) postForm(ctx context.Context, endpoint, deviceID string, form u
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for key, value := range defaultOAuthHeaders(deviceID) {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept-Language", "en-US")
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
req.Header.Set("Client-Id", ClientID)
|
||||
req.Header.Set("Origin", tvOrigin)
|
||||
req.Header.Set("Pragma", "no-cache")
|
||||
req.Header.Set("Referer", tvReferer)
|
||||
req.Header.Set("User-Agent", TVUserAgent)
|
||||
req.Header.Set("X-Device-Id", deviceID)
|
||||
req.Header.Set("Content-Type", contentTypeForm)
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -431,51 +426,10 @@ func (r oauthErrorResponse) summary() string {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// defaultOAuthHeaders builds the Twitch TV request headers used for auth requests.
|
||||
func defaultOAuthHeaders(deviceID string) map[string]string {
|
||||
return map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "en-US",
|
||||
"Cache-Control": "no-cache",
|
||||
"Client-Id": ClientID,
|
||||
"Origin": tvOrigin,
|
||||
"Pragma": "no-cache",
|
||||
"Referer": tvReferer,
|
||||
"User-Agent": TVUserAgent(),
|
||||
"X-Device-Id": deviceID,
|
||||
}
|
||||
}
|
||||
|
||||
// TVUserAgent returns the Twitch TV user agent used to impersonate the device client.
|
||||
func TVUserAgent() string {
|
||||
return tvUserAgent
|
||||
}
|
||||
|
||||
// applyValidation copies validated account metadata back onto the persisted auth state.
|
||||
func (s *State) applyValidation(validation *validationResponse, validatedAt time.Time) {
|
||||
s.Login = validation.Login
|
||||
s.UserID = validation.UserID
|
||||
s.ClientID = validation.ClientID
|
||||
s.Scopes = validation.Scopes
|
||||
s.ExpiresIn = validation.ExpiresIn
|
||||
s.ValidatedAt = validatedAt
|
||||
s.Cookies = s.persistedCookies()
|
||||
}
|
||||
|
||||
// hasScope reports whether the token scopes include one exact scope string.
|
||||
func hasScope(scopes []string, scope string) bool {
|
||||
for _, candidate := range scopes {
|
||||
if candidate == scope {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasAllScopes reports whether the token satisfies the full required scope set.
|
||||
func hasAllScopes(scopes []string, required []string) bool {
|
||||
for _, scope := range required {
|
||||
if !hasScope(scopes, scope) {
|
||||
if !slices.Contains(scopes, scope) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -511,38 +465,6 @@ func sleepContext(ctx context.Context, d time.Duration) error {
|
||||
}
|
||||
}
|
||||
|
||||
// httpClient returns the configured HTTP client or falls back to the default client.
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
// endpoints returns the configured endpoint set or the package defaults.
|
||||
func (c *Client) endpoints() Endpoints {
|
||||
if c.Endpoints == (Endpoints{}) {
|
||||
return DefaultEndpoints
|
||||
}
|
||||
return c.Endpoints
|
||||
}
|
||||
|
||||
// now returns the current time using the injected clock when one is configured.
|
||||
func (c *Client) now() time.Time {
|
||||
if c.Now != nil {
|
||||
return c.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// sleep delegates waiting to the injected hook when tests need deterministic timing.
|
||||
func (c *Client) sleep(ctx context.Context, d time.Duration) error {
|
||||
if c.Sleep != nil {
|
||||
return c.Sleep(ctx, d)
|
||||
}
|
||||
return sleepContext(ctx, d)
|
||||
}
|
||||
|
||||
// status formats and emits one auth progress line when a status sink is attached.
|
||||
func (c *Client) status(status StatusFunc, format string, args ...any) {
|
||||
if status == nil {
|
||||
|
||||
+17
-78
@@ -9,24 +9,18 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSaveStatePersistsCookieEntries(t *testing.T) {
|
||||
func TestSaveAndLoadState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "cookies.json")
|
||||
state := &State{
|
||||
AccessToken: "token",
|
||||
TokenType: "bearer",
|
||||
Scopes: RequiredScopes,
|
||||
Login: "viewer",
|
||||
UserID: "123",
|
||||
ClientID: ClientID,
|
||||
ExpiresIn: 3600,
|
||||
DeviceID: "device",
|
||||
}
|
||||
if err := SaveState(path, state); err != nil {
|
||||
@@ -37,44 +31,8 @@ func TestSaveStatePersistsCookieEntries(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.AccessToken != "token" {
|
||||
t.Fatalf("AccessToken = %q", loaded.AccessToken)
|
||||
}
|
||||
|
||||
gotNames := []string{}
|
||||
for _, cookie := range loaded.Cookies {
|
||||
gotNames = append(gotNames, cookie.Name)
|
||||
}
|
||||
wantNames := []string{"auth-token", "login", "persistent"}
|
||||
if !slices.Equal(gotNames, wantNames) {
|
||||
t.Fatalf("cookie names = %#v, want %#v", gotNames, wantNames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveStateAllowsZeroExpiresIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "cookies.json")
|
||||
state := &State{
|
||||
AccessToken: "token",
|
||||
TokenType: "bearer",
|
||||
Scopes: RequiredScopes,
|
||||
Login: "viewer",
|
||||
UserID: "123",
|
||||
ClientID: ClientID,
|
||||
ExpiresIn: 0,
|
||||
DeviceID: "device",
|
||||
}
|
||||
if err := SaveState(path, state); err != nil {
|
||||
t.Fatalf("SaveState() error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadState(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.ExpiresIn != 0 {
|
||||
t.Fatalf("ExpiresIn = %d, want 0", loaded.ExpiresIn)
|
||||
if *loaded != *state {
|
||||
t.Fatalf("state = %#v, want %#v", loaded, state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +60,7 @@ func TestLoadStateRejectsPartialAuthBundle(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("LoadState() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing token_type") {
|
||||
if !strings.Contains(err.Error(), "missing login") {
|
||||
t.Fatalf("error = %q", err)
|
||||
}
|
||||
}
|
||||
@@ -120,11 +78,10 @@ func TestReuseAuthReusesValidCachedToken(t *testing.T) {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
writeJSON(t, w, validationResponse{
|
||||
ClientID: ClientID,
|
||||
Login: "viewer",
|
||||
UserID: "123",
|
||||
Scopes: RequiredScopes,
|
||||
ExpiresIn: 3600,
|
||||
ClientID: ClientID,
|
||||
Login: "viewer",
|
||||
UserID: "123",
|
||||
Scopes: RequiredScopes,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
@@ -132,12 +89,7 @@ func TestReuseAuthReusesValidCachedToken(t *testing.T) {
|
||||
tokenFile := filepath.Join(t.TempDir(), "cookies.json")
|
||||
if err := SaveState(tokenFile, &State{
|
||||
AccessToken: "cached-token",
|
||||
TokenType: "bearer",
|
||||
Scopes: RequiredScopes,
|
||||
Login: "viewer",
|
||||
UserID: "123",
|
||||
ClientID: ClientID,
|
||||
ExpiresIn: 3600,
|
||||
DeviceID: "device",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -145,8 +97,6 @@ func TestReuseAuthReusesValidCachedToken(t *testing.T) {
|
||||
|
||||
client := NewClient(server.Client())
|
||||
client.Endpoints = Endpoints{Validate: server.URL + "/validate"}
|
||||
client.Now = func() time.Time { return time.Unix(100, 0).UTC() }
|
||||
|
||||
state, err := client.ReuseAuth(context.Background(), tokenFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -169,11 +119,10 @@ func TestEnsureAuthActivatesWhenCachedTokenMissingChatRead(t *testing.T) {
|
||||
validateCalls++
|
||||
if validateCalls == 1 {
|
||||
writeJSON(t, w, validationResponse{
|
||||
ClientID: ClientID,
|
||||
Login: "viewer",
|
||||
UserID: "123",
|
||||
Scopes: []string{"user:read:email"},
|
||||
ExpiresIn: 100,
|
||||
ClientID: ClientID,
|
||||
Login: "viewer",
|
||||
UserID: "123",
|
||||
Scopes: []string{"user:read:email"},
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -181,11 +130,10 @@ func TestEnsureAuthActivatesWhenCachedTokenMissingChatRead(t *testing.T) {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
writeJSON(t, w, validationResponse{
|
||||
ClientID: ClientID,
|
||||
Login: "viewer",
|
||||
UserID: "123",
|
||||
Scopes: RequiredScopes,
|
||||
ExpiresIn: 3600,
|
||||
ClientID: ClientID,
|
||||
Login: "viewer",
|
||||
UserID: "123",
|
||||
Scopes: RequiredScopes,
|
||||
})
|
||||
case "/device":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
@@ -214,11 +162,7 @@ func TestEnsureAuthActivatesWhenCachedTokenMissingChatRead(t *testing.T) {
|
||||
t.Fatalf("device_code = %q", got)
|
||||
}
|
||||
writeJSON(t, w, tokenResponse{
|
||||
AccessToken: "new-token",
|
||||
RefreshToken: "refresh-token",
|
||||
Scope: RequiredScopes,
|
||||
TokenType: "bearer",
|
||||
ExpiresIn: 3600,
|
||||
AccessToken: "new-token",
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
@@ -229,12 +173,7 @@ func TestEnsureAuthActivatesWhenCachedTokenMissingChatRead(t *testing.T) {
|
||||
tokenFile := filepath.Join(t.TempDir(), "cookies.json")
|
||||
if err := SaveState(tokenFile, &State{
|
||||
AccessToken: "wrong-scope-token",
|
||||
TokenType: "bearer",
|
||||
Scopes: []string{"user:read:email"},
|
||||
Login: "viewer",
|
||||
UserID: "123",
|
||||
ClientID: ClientID,
|
||||
ExpiresIn: 3600,
|
||||
DeviceID: "device",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
+4
-39
@@ -9,30 +9,15 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const authFileName = "cookies.json"
|
||||
|
||||
// Cookie stores one persisted cookie-like name/value entry in the auth bundle.
|
||||
type Cookie struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// State is the persisted cwd auth bundle for the app.
|
||||
type State struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Login string `json:"login"`
|
||||
UserID string `json:"user_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
ValidatedAt time.Time `json:"validated_at"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Cookies []Cookie `json:"cookies,omitempty"`
|
||||
AccessToken string `json:"access_token"`
|
||||
Login string `json:"login"`
|
||||
DeviceID string `json:"device_id"`
|
||||
}
|
||||
|
||||
// DefaultPath returns the cwd auth bundle path.
|
||||
@@ -72,10 +57,7 @@ func SaveState(path string, state *State) error {
|
||||
return err
|
||||
}
|
||||
|
||||
copy := *state
|
||||
copy.Cookies = copy.persistedCookies()
|
||||
|
||||
data, err := json.MarshalIndent(copy, "", " ")
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -83,30 +65,13 @@ func SaveState(path string, state *State) error {
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
// persistedCookies rebuilds the cookie-like entries that must travel with the saved auth bundle.
|
||||
func (s *State) persistedCookies() []Cookie {
|
||||
return []Cookie{
|
||||
{Name: "auth-token", Value: s.AccessToken},
|
||||
{Name: "login", Value: s.Login},
|
||||
{Name: "persistent", Value: s.UserID},
|
||||
}
|
||||
}
|
||||
|
||||
// validate rejects partial auth bundles so loading and saving use one canonical shape.
|
||||
func (s *State) validate() error {
|
||||
switch {
|
||||
case s.AccessToken == "":
|
||||
return fmt.Errorf("missing access_token")
|
||||
case s.TokenType == "":
|
||||
return fmt.Errorf("missing token_type")
|
||||
case s.Login == "":
|
||||
return fmt.Errorf("missing login")
|
||||
case s.UserID == "":
|
||||
return fmt.Errorf("missing user_id")
|
||||
case s.ClientID == "":
|
||||
return fmt.Errorf("missing client_id")
|
||||
case len(s.Scopes) == 0:
|
||||
return fmt.Errorf("missing scopes")
|
||||
case s.DeviceID == "":
|
||||
return fmt.Errorf("missing device_id")
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user