From 1aa7a8c9bdb09c4ce20dccd013b3fb530e6894a6 Mon Sep 17 00:00:00 2001 From: Evan Fiordeliso Date: Mon, 4 Mar 2024 18:14:38 -0500 Subject: [PATCH] Add authentication client --- api.go | 9 +- api/api.go | 31 ++++-- auth/authorize.go | 95 +++++++++++++++++++ auth/callback.go | 66 +++++++++++++ auth/claims.go | 73 ++++++++++++++ auth/client.go | 55 +++++++++++ auth/scopes.go | 214 ++++++++++++++++++++++++++++++++++++++++++ auth/state.go | 24 +++++ auth/state_storage.go | 44 +++++++++ auth/token.go | 80 ++++++++++++++++ auth/token_source.go | 38 ++++++++ go.mod | 12 ++- go.sum | 24 ++++- 13 files changed, 753 insertions(+), 12 deletions(-) create mode 100644 auth/authorize.go create mode 100644 auth/callback.go create mode 100644 auth/claims.go create mode 100644 auth/client.go create mode 100644 auth/scopes.go create mode 100644 auth/state.go create mode 100644 auth/state_storage.go create mode 100644 auth/token.go create mode 100644 auth/token_source.go diff --git a/api.go b/api.go index aeafd25..ddee1ab 100644 --- a/api.go +++ b/api.go @@ -1,7 +1,10 @@ package twitch -import "go.fifitido.net/twitch/api" +import ( + "go.fifitido.net/twitch/api" + "go.fifitido.net/twitch/auth" +) -func NewAPI() *api.API { - return api.NewDefault() +func NewAPI(authClient *auth.Client) *api.API { + return api.NewDefault(authClient) } diff --git a/api/api.go b/api/api.go index b9eac4f..cb3adf1 100644 --- a/api/api.go +++ b/api/api.go @@ -32,13 +32,16 @@ import ( "go.fifitido.net/twitch/api/users" "go.fifitido.net/twitch/api/videos" "go.fifitido.net/twitch/api/whispers" + "go.fifitido.net/twitch/auth" + "golang.org/x/oauth2" ) const HelixBaseUrl = "https://api.twitch.tv/helix" type API struct { - client *http.Client - baseUrl *url.URL + client *http.Client + baseUrl *url.URL + authClient *auth.Client Ads *ads.Ads Analytics *analytics.Analytics @@ -70,10 +73,11 @@ type API struct { Whispers *whispers.Whispers } -func New(client *http.Client, baseUrl *url.URL) *API { +func New(client *http.Client, baseUrl *url.URL, authClient *auth.Client) *API { return &API{ - client: client, - baseUrl: baseUrl, + client: client, + baseUrl: baseUrl, + authClient: authClient, Ads: ads.New(client, baseUrl), Analytics: analytics.New(client, baseUrl), @@ -106,9 +110,22 @@ func New(client *http.Client, baseUrl *url.URL) *API { } } -func NewDefault() *API { +func NewDefault(authClient *auth.Client) *API { client := &http.Client{} baseUrl, _ := url.Parse(HelixBaseUrl) - return New(client, baseUrl) + return New(client, baseUrl, authClient) +} + +func (a *API) WithClient(client *http.Client) *API { + return New(client, a.baseUrl, a.authClient) +} + +func (a *API) WithAuthToken(token *auth.Token) *API { + return a.WithClient(&http.Client{ + Transport: &oauth2.Transport{ + Source: a.authClient.TokenSource(token), + Base: a.client.Transport, + }, + }) } diff --git a/auth/authorize.go b/auth/authorize.go new file mode 100644 index 0000000..0fd9b60 --- /dev/null +++ b/auth/authorize.go @@ -0,0 +1,95 @@ +package auth + +import ( + "net/http" + "net/url" + + "github.com/google/go-querystring/query" +) + +type AuthorizeParams struct { + // A string-encoded JSON object that specifies the claims to include in the ID token. + // For information about claims, see Requesting claims: https://dev.twitch.tv/docs/authentication/getting-tokens-oidc/#requesting-claims + // + // Only used for OIDC auth flows + Claims *Claims `url:"claims"` + + // Set to true to force the user to re-authorize your app’s access to their resources. + // The default is false. + ForceVerify *bool `url:"force_verify"` + + // Although optional, you are strongly encouraged to pass a nonce string to help + // prevent Cross-Site Request Forgery (CSRF) attacks. The server returns this string + // to you in the ID token’s list of claims. If this string doesn’t match the nonce + // string that you passed, ignore the response. The nonce string should be randomly + // generated and unique for each OAuth request. + // + // Only used for OIDC auth flows + Nonce *string `url:"nonce"` + + // Must be set to code for Authorization code grant flow. + // Recommended for Server-to-Server flows. (with backend) + // + // Must be set to token for Implicit grant flow. + // Recommended for Client-to-Server flows. (no backend) + ResponseType string `url:"response_type"` + + // A space-delimited list of scopes. The APIs that you’re calling identify the + // scopes you must list. The list must include the openid scope. Don’t forget to + // URL encode the list. + Scope []Scope `url:"scope,space"` + + // Although optional, you are strongly encouraged to pass a state string to help + // prevent Cross-Site Request Forgery (CSRF) attacks. The server returns this string + // to you in your redirect URI (see the state parameter in the fragment portion of + // the URI). If this string doesn’t match the state string that you passed, ignore + // the response. The state string should be randomly generated and unique for each + // OAuth request. + State *string `url:"state"` +} + +const AuthorizeUrl = "https://id.twitch.tv/oauth2/authorize" + +// AuthorizeUrl returns the URL to redirect the user to for authorization. +func (c *Client) AuthorizeUrl(params *AuthorizeParams) *url.URL { + v, _ := query.Values(params) + v.Set("client_id", c.clientId) + v.Set("redirect_uri", c.redirectUri) + url, _ := url.Parse(AuthorizeUrl) + url.RawQuery = v.Encode() + return url +} + +type AuthorizeHandler struct { + client *Client + scopes []Scope +} + +var _ http.Handler = (*AuthorizeHandler)(nil) + +// AuthorizeHandler returns an http.Handler that redirects the user to the +// authorization URL. +func (c *Client) AuthorizeHandler(scopes []Scope) http.Handler { + return &AuthorizeHandler{ + client: c, + scopes: scopes, + } +} + +// ServeHTTP implements http.Handler. +func (h *AuthorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + state := GenerateState() + + if err := h.client.stateStorage.Save(w, state); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + url := h.client.AuthorizeUrl(&AuthorizeParams{ + ResponseType: "code", + Scope: h.scopes, + State: &state, + }) + + http.Redirect(w, r, url.String(), http.StatusFound) +} diff --git a/auth/callback.go b/auth/callback.go new file mode 100644 index 0000000..99a7795 --- /dev/null +++ b/auth/callback.go @@ -0,0 +1,66 @@ +package auth + +import ( + "fmt" + "net/http" +) + +type CallbackHandler struct { + client *Client + handler TokenHandler +} + +var _ http.Handler = (*CallbackHandler)(nil) + +// CallbackHandler returns an http.Handler that handles callback responses +// from the twitch authentication server. +func (c *Client) CallbackHandler(h TokenHandler) http.Handler { + return &CallbackHandler{ + client: c, + handler: h, + } +} + +// ServeHTTP implements http.Handler. +func (c *CallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + state := q.Get("state") + + if state == "" { + http.Error(w, "state is empty", http.StatusBadRequest) + return + } + + storedState, err := c.client.stateStorage.Get(r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if state != storedState { + http.Error(w, "state mismatch", http.StatusBadRequest) + return + } + + if q.Has("error") { + err := q.Get("error") + desc := q.Get("error_description") + + errMsg := fmt.Sprintf("%s: %s", err, desc) + + http.Error(w, errMsg, http.StatusBadRequest) + return + } + + code := q.Get("code") + scope := q.Get("scope") + _ = scope + + token, err := c.client.GetToken(code) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + c.handler.Handle(state, token.AccessToken) +} diff --git a/auth/claims.go b/auth/claims.go new file mode 100644 index 0000000..85fa1be --- /dev/null +++ b/auth/claims.go @@ -0,0 +1,73 @@ +package auth + +import ( + "encoding/json" + "net/url" + + "github.com/google/go-querystring/query" +) + +type Claim string + +const ( + // The email address of the user that authorized the app. + Email Claim = "email" + + // A Boolean value that indicates whether Twitch has verified the user’s email address. Is true if Twitch has verified the user’s email address. + EmailVerified Claim = "email_verified" + + // A URL to the user’s profile image if they included one; otherwise, a default image. + Picture Claim = "picture" + + // The user’s display name. + PreferredUsername Claim = "preferred_username" + + // The date and time that the user last updated their profile. + UpdatedAt Claim = "updated_at" +) + +// Claims identify information about the user that authorized your app. +// +// To include the non-default claims, include the claims query parameter in your /authorize request. +// Set the claims query parameter to a string-encoded JSON object. The JSON object may contain the id_token and userinfo fields. +// Set id_token field to an object that specifies the claims that you want to include in the ID token, +// and set the userinfo field to an object that specifies the claims that you want to retrieve using the UserInfo endpoint. +// Each claim is a name/value pair, where name is the claim (e.g., email) and value is null. +// +// You may specify the claims in the id_tokenfield or the userinfo field or both fields. +// There are no uniqueness constraints — you may specify the same claim in both fields. +// The following claims object tells the server to include the user’s email and email +// verification state in the ID token and make the user’s profile image available through the UserInfo endpoint. +// +// { +// "id_token": { +// "email": null, +// "email_verified": null +// }, +// "userinfo": { +// "picture": null +// } +// } +// +// The following example shows the claims query parameter set to the above claims object. +// +// claims={"id_token":{"email":null,"email_verified":null},"userinfo":{"picture":null}} +// +// NOTE If you specify the email or email_verified claims, you must include the user:read:email scope in your list of scopes. +type Claims struct { + IDToken map[Claim]any `json:"id_token"` + UserInfo map[Claim]any `json:"user_info"` +} + +var _ query.Encoder = (*Claims)(nil) + +// EncodeValues implements query.Encoder. +func (c *Claims) EncodeValues(key string, v *url.Values) error { + data, err := json.Marshal(c) + if err != nil { + return err + } + + v.Set(key, string(data)) + return nil +} diff --git a/auth/client.go b/auth/client.go new file mode 100644 index 0000000..014adf3 --- /dev/null +++ b/auth/client.go @@ -0,0 +1,55 @@ +package auth + +type Client struct { + clientId string + clientSecret string + redirectUri string + + stateStorage StateStorage +} + +func NewClient(clientId string, clientSecret string, redirectUri string) *Client { + return &Client{ + clientId: clientId, + clientSecret: clientSecret, + redirectUri: redirectUri, + + stateStorage: NewHttpCookieStateStorage(StateStorageCookie), + } +} + +// GetToken exchanges an authorization code for an access token. +// +// https://dev.twitch.tv/docs/authentication/getting-tokens-oidc/#oidc-authorization-code-grant-flow +func (c *Client) GetToken(code string) (*Token, error) { + return GetToken(&GetTokenParams{ + ClientId: c.clientId, + ClientSecret: c.clientSecret, + Code: code, + GrantType: "authorization_code", + RedirectUri: c.redirectUri, + }) +} + +// RefreshToken exchanges a refresh token for an access token. +// +// https://dev.twitch.tv/docs/authentication/refresh-tokens/ +func (c *Client) RefreshToken(token *Token) (*Token, error) { + return GetToken(&GetTokenParams{ + ClientId: c.clientId, + ClientSecret: c.clientSecret, + Code: token.RefreshToken, + GrantType: "refresh_token", + RedirectUri: c.redirectUri, + }) +} + +// WithStateStorage sets the instance's state storage, +// which is used to store the state parameter between requests. +// +// By default, the http cookie state storage is used. +func (c *Client) WithStateStorage(storage StateStorage) *Client { + c.stateStorage = storage + + return c +} diff --git a/auth/scopes.go b/auth/scopes.go new file mode 100644 index 0000000..2330291 --- /dev/null +++ b/auth/scopes.go @@ -0,0 +1,214 @@ +package auth + +type Scope string + +func (s Scope) String() string { + return string(s) +} + +// Twitch API scopes +const ( + // View analytics data for the Twitch Extensions owned by the authenticated account. + ScopeAnalyticsReadExtensions Scope = "analytics:read:extensions" + + // View analytics data for the games owned by the authenticated account. + ScopeAnalyticsReadGames Scope = "analytics:read:games" + + // View Bits information for a channel. + ScopeBitsRead Scope = "bits:read" + + // Manage ads schedule for a channel. + ScopeChannelManageAds Scope = "channel:manage:ads" + + // Read the ads schedule and details on your channel. + ScopeChannelReadAds Scope = "channel:read:ads" + + // Manage a channel’s broadcast configuration, including updating channel configuration and managing stream markers and stream tags. + ScopeChannelManageBroadcast Scope = "channel:manage:broadcast" + + // Read charity campaign details and user donations on your channel. + ScopeChannelReadCharity Scope = "channel:read:charity" + + // Run commercials on a channel. + ScopeChannelEditCommercial Scope = "channel:edit:commercial" + + // View a list of users with the editor role for a channel. + ScopeChannelReadEditors Scope = "channel:read:editors" + + // Manage a channel’s Extension configuration, including activating Extensions. + ScopeChannelManageExtensions Scope = "channel:manage:extensions" + + // View Creator Goals for a channel. + ScopeChannelReadGoals Scope = "channel:read:goals" + + // Read Guest Star details for your channel. + ScopeChannelReadGuestStar Scope = "channel:read:guest_star" + + // Manage Guest Star for your channel. + ScopeChannelManageGuestStar Scope = "channel:manage:guest_star" + + // View Hype Train information for a channel. + ScopeChannelReadHypeTrain Scope = "channel:read:hype_train" + + // Add or remove the moderator role from users in your channel. + ScopeChannelManageModerators Scope = "channel:manage:moderators" + + // View a channel’s polls. + ScopeChannelReadPolls Scope = "channel:read:polls" + + // Manage a channel’s polls. + ScopeChannelManagePolls Scope = "channel:manage:polls" + + // View a channel's Channel Points Predictions. + ScopeChannelReadPredictions Scope = "channel:read:predictions" + + // Manage a channel's Channel Points Predictions. + ScopeChannelManagePredictions Scope = "channel:manage:predictions" + + // Manage a channel raiding another channel. + ScopeChannelManageRaid Scope = "channel:manage:raids" + + // View Channel Points custom rewards and their redemptions on a channel. + ScopeChannelReadRedemptions Scope = "channel:read:redemptions" + + // Manage Channel Points custom rewards on a channel. + ScopeChannelManageRedemptions Scope = "channel:manage:redemptions" + + // Manage a channel's stream schedule. + ScopeChannelManageSchedule Scope = "channel:manage:schedule" + + // View an authorized user's stream key. + ScopeChannelReadStreamKey Scope = "channel:read:stream_key" + + // View a list of all subscribers to a channel and check if a user is subscribed to a channel. + ScopeChannelReadSubscriptions Scope = "channel:read:subscriptions" + + // Manage a channel’s videos, including deleting videos. + ScopeChannelManagerVideos Scope = "channel:manage:videos" + + // Read the list of VIPs in your channel. + ScopeChannelReadVips Scope = "channel:read:vips" + + // Add or remove the VIP role from users in your channel. + ScopeChannelManageVips Scope = "channel:manage:vips" + + // Manage Clips for a channel. + ScopeClipsEdit Scope = "clips:edit" + + // View a channel’s moderation data including Moderators, Bans, Timeouts, and Automod settings. + ScopeModerationRead Scope = "moderation:read" + + // Send announcements in channels where you have the moderator role. + ScopeModerationManageAnnouncements Scope = "moderation:manage:announcements" + + // Manage messages held for review by AutoMod in channels where you are a moderator. + ScopeModerationManageAutoMod Scope = "moderation:manage:automod" + + // View a broadcaster’s AutoMod settings. + ScopeModerationReadAutoModSettings Scope = "moderation:read:automod_settings" + + // Manage a broadcaster’s AutoMod settings. + ScopeModerationManageAutoModSettings Scope = "moderation:manage:automod_settings" + + // Ban and unban users. + ScopeModeratorManagerBannedUsers Scope = "moderator:manage:banned_users" + + // View a broadcaster’s list of blocked terms. + ScopeModeratorReadBlockedTerms Scope = "moderator:read:blocked_terms" + + // Manage a broadcaster’s list of blocked terms. + ScopeModeratorManageBlockedTerms Scope = "moderator:manage:blocked_terms" + + // Delete chat messages in channels where you have the moderator role + ScopeModerationManageChatMessages Scope = "moderation:manage:chat_messages" + + // View a broadcaster’s chat room settings. + ScopeModerationReadChatSettings Scope = "moderation:read:chat_settings" + + // Manage a broadcaster’s chat room settings. + ScopeModerationManageChatSettings Scope = "moderation:manage:chat_settings" + + // View the chatters in a broadcaster’s chat room. + ScopeModerationReadChatters Scope = "moderation:read:chatters" + + // Read the followers of a broadcaster. + ScopeModerationReadFollowers Scope = "moderation:read:followers" + + // Read Guest Star details for channels where you are a Guest Star moderator. + ScopeModerationReadGuestStars Scope = "moderation:read:guest_stars" + + // Manage Guest Star for channels where you are a Guest Star moderator. + ScopeModerationManageGuestStars Scope = "moderation:manage:guest_stars" + + // View a broadcaster’s Shield Mode status. + ScopeModerationReadShieldMode Scope = "moderation:read:shield_mode" + + // Manage a broadcaster’s Shield Mode status. + ScopeModerationManageShieldMode Scope = "moderation:manage:shield_mode" + + // View a broadcaster’s shoutouts. + ScopeModerationReadShoutouts Scope = "moderation:read:shoutouts" + + // Manage a broadcaster’s shoutouts. + ScopeModerationManageShoutouts Scope = "moderation:manage:shoutouts" + + // Manage a user object. + ScopeUserEdit Scope = "user:edit" + + // View the block list of a user. + ScopeUserReadBlockedUsers Scope = "user:read:blocked_users" + + // Manage the block list of a user. + ScopeUserManageBlockedUsers Scope = "user:manage:blocked_users" + + // View a user’s broadcasting configuration, including Extension configurations. + ScopeUserReadBroadcast Scope = "user:read:broadcast" + + // Update the color used for the user’s name in chat. + ScopeUserEditChatColor Scope = "user:edit:chat_color" + + // View a user’s email address. + ScopeUserReadEmail Scope = "user:read:email" + + // View the list of channels a user follows. + ScopeUserReadFollows Scope = "user:read:follows" + + // Read the list of channels you have moderator privileges in. + ScopeUserReadModeratedChannels = "user:read:moderated_channels" + + // View if an authorized user is subscribed to specific channels. + ScopeUserReadSubscriptions = "user:read:subscriptions" + + // Read whispers that you send and receive, and send whispers on your behalf. + ScopeUserManageWhispers = "user:manage:whispers" +) + +// Chat and PubSub scopes +const ( + // Allows the client’s bot users access to a channel. + ScopeChannelBot Scope = "channel:bot" + + // Perform moderation actions in a channel. The user requesting the scope must be a moderator in the channel. + ScopeChannelModerate Scope = "channel:moderate" + + // Send live stream chat messages using an IRC connection. + ScopeChatEdit Scope = "chat:edit" + + // View live stream chat messages using an IRC connection. + ScopeChatRead Scope = "chat:read" + + // Allow client’s bot to act as this user. + ScopeUserBot Scope = "user:bot" + + // View live stream chat and room messages using EventSub. + ScopeUserReadChat Scope = "user:read:chat" + + // Send live stream chat messages using Send Chat Message API. + ScopeUserWriteChat Scope = "user:write:chat" + + // View your whisper messages. + ScopeWhispersRead Scope = "whispers:read" + + // Send whisper messages. + ScopeWhispersEdit Scope = "whispers:edit" +) diff --git a/auth/state.go b/auth/state.go new file mode 100644 index 0000000..9b1565a --- /dev/null +++ b/auth/state.go @@ -0,0 +1,24 @@ +package auth + +import ( + "crypto/rand" + "math/big" + mrand "math/rand" +) + +const ( + stateLength = 32 + stateChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-" +) + +func GenerateState() string { + b := make([]byte, stateLength) + for i := range b { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(stateChars)))) + if err != nil { + num = big.NewInt(mrand.Int63n(int64(len(stateChars)))) + } + b[i] = stateChars[num.Int64()] + } + return string(b) +} diff --git a/auth/state_storage.go b/auth/state_storage.go new file mode 100644 index 0000000..640d482 --- /dev/null +++ b/auth/state_storage.go @@ -0,0 +1,44 @@ +package auth + +import ( + "net/http" +) + +type StateStorage interface { + Get(r *http.Request) (string, error) + Save(w http.ResponseWriter, code string) error +} + +const ( + StateStorageCookie = "state" +) + +type HttpCookieStateStorage struct { + cookieName string +} + +func NewHttpCookieStateStorage(cookieName string) *HttpCookieStateStorage { + return &HttpCookieStateStorage{ + cookieName: cookieName, + } +} + +var _ StateStorage = (*HttpCookieStateStorage)(nil) + +func (s *HttpCookieStateStorage) Get(r *http.Request) (string, error) { + code, err := r.Cookie(s.cookieName) + if err != nil { + return "", err + } + + if err := code.Valid(); err != nil { + return "", err + } + + return code.Value, nil +} + +func (s *HttpCookieStateStorage) Save(w http.ResponseWriter, state string) error { + http.SetCookie(w, &http.Cookie{Name: s.cookieName, Value: state}) + return nil +} diff --git a/auth/token.go b/auth/token.go new file mode 100644 index 0000000..2ecedb4 --- /dev/null +++ b/auth/token.go @@ -0,0 +1,80 @@ +package auth + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/google/go-querystring/query" + "golang.org/x/oauth2" +) + +type Token struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + Expiry time.Time `json:"-"` + + // Only present when using OIDC. + IdToken *string `json:"id_token"` + + RefreshToken string `json:"refresh_token"` + Scope []string `json:"scope"` + TokenType string `json:"token_type"` +} + +func (t *Token) Valid() bool { + return t.AccessToken != "" && t.Expiry.After(time.Now()) +} + +func (t *Token) Underlying() *oauth2.Token { + return &oauth2.Token{ + AccessToken: t.AccessToken, + TokenType: t.TokenType, + RefreshToken: t.RefreshToken, + Expiry: t.Expiry, + } +} + +const TokenUrl = "https://id.twitch.tv/oauth2/token" + +type GetTokenParams struct { + ClientId string `url:"client_id"` + ClientSecret string `url:"client_secret"` + Code string `url:"code"` + GrantType string `url:"grant_type"` + RedirectUri string `url:"redirect_uri"` +} + +func GetToken(params *GetTokenParams) (*Token, error) { + v, err := query.Values(params) + if err != nil { + return nil, err + } + + res, err := http.Get(TokenUrl + "?" + v.Encode()) + if err != nil { + return nil, err + } + defer res.Body.Close() + + var token Token + if err := json.NewDecoder(res.Body).Decode(&token); err != nil { + return nil, err + } + + token.Expiry = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second) + + return &token, nil +} + +type TokenHandler interface { + Handle(state string, token string) +} + +type TokenHandlerFunc func(state string, token string) + +var _ TokenHandler = (*TokenHandlerFunc)(nil) + +func (f TokenHandlerFunc) Handle(state string, token string) { + f(state, token) +} diff --git a/auth/token_source.go b/auth/token_source.go new file mode 100644 index 0000000..9368d6c --- /dev/null +++ b/auth/token_source.go @@ -0,0 +1,38 @@ +package auth + +import ( + "sync" + + "golang.org/x/oauth2" +) + +type TokenSource struct { + client *Client + token *Token + + mu sync.Mutex +} + +func (c *Client) TokenSource(token *Token) oauth2.TokenSource { + return &TokenSource{ + client: c, + token: token, + } +} + +func (ts *TokenSource) Token() (*oauth2.Token, error) { + ts.mu.Lock() + defer ts.mu.Unlock() + + if ts.token.Valid() { + return ts.token.Underlying(), nil + } + + token, err := ts.client.RefreshToken(ts.token) + if err != nil { + return nil, err + } + + ts.token = token + return ts.token.Underlying(), nil +} diff --git a/go.mod b/go.mod index 39d7188..45747d4 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,14 @@ module go.fifitido.net/twitch go 1.21.7 -require github.com/google/go-querystring v1.1.0 +require ( + github.com/google/go-querystring v1.1.0 + golang.org/x/oauth2 v0.17.0 +) + +require ( + github.com/golang/protobuf v1.5.3 // indirect + golang.org/x/net v0.21.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.31.0 // indirect +) diff --git a/go.sum b/go.sum index f99081b..2bd9adc 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,27 @@ -github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= +golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=