Add authentication client
This commit is contained in:
parent
7f60603af4
commit
1aa7a8c9bd
9
api.go
9
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)
|
||||
}
|
||||
|
|
31
api/api.go
31
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
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.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=
|
||||
|
|
Loading…
Reference in New Issue