From 2b457249156a7ec744cbf6fb0783f6b2662d3bd3 Mon Sep 17 00:00:00 2001 From: Branden J Brown Date: Sun, 7 Apr 2024 10:44:11 -0500 Subject: [PATCH] twitch: new package for twitch api --- robot.go | 52 ++++------------------------------------- twitch/token.go | 61 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 47 deletions(-) create mode 100644 twitch/token.go diff --git a/robot.go b/robot.go index 7f6dcd6..3739de0 100644 --- a/robot.go +++ b/robot.go @@ -3,10 +3,8 @@ package main import ( "context" "crypto/tls" - "encoding/json" "errors" "fmt" - "io" "log/slog" "net/http" "strings" @@ -21,6 +19,7 @@ import ( "github.com/zephyrtronium/robot/brain/kvbrain" "github.com/zephyrtronium/robot/channel" "github.com/zephyrtronium/robot/privacy" + "github.com/zephyrtronium/robot/twitch" ) // Robot is the overall configuration for the bot. @@ -115,13 +114,15 @@ func twitchToken(ctx context.Context, tokens auth.TokenSource) (*oauth2.Token, e if err != nil { return nil, fmt.Errorf("couldn't obtain access token for TMI login: %w", err) } + client := http.Client{Timeout: 30 * time.Second} for range 5 { - err := validateTwitch(ctx, tok) + v, err := twitch.Validate(ctx, &client, tok) + slog.InfoContext(ctx, "Twitch validation", slog.Any("response", v), slog.Any("err", err)) switch { case err == nil: // Current token is good. return tok, nil - case errors.Is(err, errNeedRefresh): + case errors.Is(err, twitch.ErrNeedRefresh): // Refresh and try again. tok, err = tokens.Refresh(ctx, tok) if err != nil { @@ -134,47 +135,6 @@ func twitchToken(ctx context.Context, tokens auth.TokenSource) (*oauth2.Token, e return nil, fmt.Errorf("giving up on refresh retries") } -// validateTwitch validates a Twitch access token. If the returned error Is -// errNeedRefresh, then the caller should refresh it and try again. -func validateTwitch(ctx context.Context, tok *oauth2.Token) error { - req, err := http.NewRequestWithContext(ctx, "GET", "https://id.twitch.tv/oauth2/validate", nil) - if err != nil { - return fmt.Errorf("couldn't make validate request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+tok.AccessToken) - client := http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("couldn't validate access token: %w", err) - } - defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if err != nil { - return fmt.Errorf("couldn't read token validation response: %w", err) - } - var s struct { - ClientID string `json:"client_id"` - Login string `json:"login"` - Scopes []string `json:"scopes"` - UserID string `json:"user_id"` - ExpiresIn int `json:"expires_in"` - Message string `json:"message"` - Status int `json:"status"` - } - if err := json.Unmarshal(body, &s); err != nil { - return fmt.Errorf("couldn't unmarshal token validation response: %w", err) - } - slog.InfoContext(ctx, "token validation", slog.Any("result", s)) - if resp.StatusCode == http.StatusUnauthorized { - // Token expired or otherwise invalid. We need a refresh. - return fmt.Errorf("token validation failed: %s (%w)", s.Message, errNeedRefresh) - } - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("token validation failed: %s (%s)", s.Message, resp.Status) - } - return nil -} - func (robo *Robot) tmiLoop(ctx context.Context, group *errgroup.Group, send chan<- *tmi.Message, recv <-chan *tmi.Message) { for { select { @@ -259,5 +219,3 @@ func (l *tmiSlog) Recv(s string) { l.l.Debug("TMI recv", slog.String("message" func (l *tmiSlog) Ping(s string) { l.l.Log(context.Background(), slog.LevelDebug-1, "TMI ping", slog.String("message", s)) } - -var errNeedRefresh = errors.New("need refresh") diff --git a/twitch/token.go b/twitch/token.go new file mode 100644 index 0000000..69bc3cc --- /dev/null +++ b/twitch/token.go @@ -0,0 +1,61 @@ +package twitch + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "golang.org/x/oauth2" +) + +// Validate checks the status of an access token. +// If the API response indicates that the access token is invalid, the returned +// error wraps [ErrNeedRefresh]. +// The returned Validation may be non-nil even if the error is also non-nil. +func Validate(ctx context.Context, client *http.Client, tok *oauth2.Token) (*Validation, error) { + req, err := http.NewRequestWithContext(ctx, "GET", "https://id.twitch.tv/oauth2/validate", nil) + if err != nil { + return nil, fmt.Errorf("couldn't make validate request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+tok.AccessToken) + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("couldn't validate access token: %w", err) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("couldn't read token validation response: %w", err) + } + resp.Body.Close() + var s Validation + if err := json.Unmarshal(body, &s); err != nil { + return nil, fmt.Errorf("couldn't unmarshal token validation response: %w", err) + } + switch resp.StatusCode { + case http.StatusOK: // do nothing + case http.StatusUnauthorized: + err = fmt.Errorf("token validation failed: %s (%w)", s.Message, ErrNeedRefresh) + default: + err = fmt.Errorf("token validation failed: %s (%s)", s.Message, resp.Status) + } + return &s, err +} + +// Validation describes an access token's validation status. +type Validation struct { + ClientID string `json:"client_id"` + Login string `json:"login"` + Scopes []string `json:"scopes"` + UserID string `json:"user_id"` + ExpiresIn int `json:"expires_in"` + + Message string `json:"message"` + Status int `json:"status"` +} + +// ErrNeedRefresh is an error returned by Twitch API operations when a response +// indicates that the access token needs to be refreshed. +var ErrNeedRefresh = errors.New("need refresh")