Add authentication client
This commit is contained in:
parent
7f60603af4
commit
1aa7a8c9bd
9
api.go
9
api.go
|
@ -1,7 +1,10 @@
|
||||||
package twitch
|
package twitch
|
||||||
|
|
||||||
import "go.fifitido.net/twitch/api"
|
import (
|
||||||
|
"go.fifitido.net/twitch/api"
|
||||||
|
"go.fifitido.net/twitch/auth"
|
||||||
|
)
|
||||||
|
|
||||||
func NewAPI() *api.API {
|
func NewAPI(authClient *auth.Client) *api.API {
|
||||||
return api.NewDefault()
|
return api.NewDefault(authClient)
|
||||||
}
|
}
|
||||||
|
|
31
api/api.go
31
api/api.go
|
@ -32,13 +32,16 @@ import (
|
||||||
"go.fifitido.net/twitch/api/users"
|
"go.fifitido.net/twitch/api/users"
|
||||||
"go.fifitido.net/twitch/api/videos"
|
"go.fifitido.net/twitch/api/videos"
|
||||||
"go.fifitido.net/twitch/api/whispers"
|
"go.fifitido.net/twitch/api/whispers"
|
||||||
|
"go.fifitido.net/twitch/auth"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
const HelixBaseUrl = "https://api.twitch.tv/helix"
|
const HelixBaseUrl = "https://api.twitch.tv/helix"
|
||||||
|
|
||||||
type API struct {
|
type API struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
baseUrl *url.URL
|
baseUrl *url.URL
|
||||||
|
authClient *auth.Client
|
||||||
|
|
||||||
Ads *ads.Ads
|
Ads *ads.Ads
|
||||||
Analytics *analytics.Analytics
|
Analytics *analytics.Analytics
|
||||||
|
@ -70,10 +73,11 @@ type API struct {
|
||||||
Whispers *whispers.Whispers
|
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{
|
return &API{
|
||||||
client: client,
|
client: client,
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
|
authClient: authClient,
|
||||||
|
|
||||||
Ads: ads.New(client, baseUrl),
|
Ads: ads.New(client, baseUrl),
|
||||||
Analytics: analytics.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{}
|
client := &http.Client{}
|
||||||
baseUrl, _ := url.Parse(HelixBaseUrl)
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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"
|
||||||
|
)
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
12
go.mod
12
go.mod
|
@ -2,4 +2,14 @@ module go.fifitido.net/twitch
|
||||||
|
|
||||||
go 1.21.7
|
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
|
||||||
|
)
|
||||||
|
|
24
go.sum
24
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.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 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
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=
|
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=
|
||||||
|
|
Loading…
Reference in New Issue