refactor: simplify codebase

This commit is contained in:
2026-07-17 22:26:53 +00:00
parent 0ae1b094c5
commit 20209a9b6d
26 changed files with 291 additions and 904 deletions
+29 -107
View File
@@ -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
View File
@@ -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
View File
@@ -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: