Add authentication client

This commit is contained in:
Evan Fiordeliso 2024-03-04 18:14:38 -05:00
parent 7f60603af4
commit 1aa7a8c9bd
13 changed files with 753 additions and 12 deletions

9
api.go
View File

@ -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)
} }

View File

@ -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,
},
})
} }

95
auth/authorize.go Normal file
View File

@ -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 apps 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 tokens list of claims. If this string doesnt 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 youre calling identify the
// scopes you must list. The list must include the openid scope. Dont 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 doesnt 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)
}

66
auth/callback.go Normal file
View File

@ -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)
}

73
auth/claims.go Normal file
View File

@ -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 users email address. Is true if Twitch has verified the users email address.
EmailVerified Claim = "email_verified"
// A URL to the users profile image if they included one; otherwise, a default image.
Picture Claim = "picture"
// The users 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 users email and email
// verification state in the ID token and make the users 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
}

55
auth/client.go Normal file
View File

@ -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
}

214
auth/scopes.go Normal file
View File

@ -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 channels 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 channels 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 channels polls.
ScopeChannelReadPolls Scope = "channel:read:polls"
// Manage a channels 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 channels 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 channels 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 broadcasters AutoMod settings.
ScopeModerationReadAutoModSettings Scope = "moderation:read:automod_settings"
// Manage a broadcasters AutoMod settings.
ScopeModerationManageAutoModSettings Scope = "moderation:manage:automod_settings"
// Ban and unban users.
ScopeModeratorManagerBannedUsers Scope = "moderator:manage:banned_users"
// View a broadcasters list of blocked terms.
ScopeModeratorReadBlockedTerms Scope = "moderator:read:blocked_terms"
// Manage a broadcasters 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 broadcasters chat room settings.
ScopeModerationReadChatSettings Scope = "moderation:read:chat_settings"
// Manage a broadcasters chat room settings.
ScopeModerationManageChatSettings Scope = "moderation:manage:chat_settings"
// View the chatters in a broadcasters 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 broadcasters Shield Mode status.
ScopeModerationReadShieldMode Scope = "moderation:read:shield_mode"
// Manage a broadcasters Shield Mode status.
ScopeModerationManageShieldMode Scope = "moderation:manage:shield_mode"
// View a broadcasters shoutouts.
ScopeModerationReadShoutouts Scope = "moderation:read:shoutouts"
// Manage a broadcasters 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 users broadcasting configuration, including Extension configurations.
ScopeUserReadBroadcast Scope = "user:read:broadcast"
// Update the color used for the users name in chat.
ScopeUserEditChatColor Scope = "user:edit:chat_color"
// View a users 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 clients 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 clients 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"
)

24
auth/state.go Normal file
View File

@ -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)
}

44
auth/state_storage.go Normal file
View File

@ -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
}

80
auth/token.go Normal file
View File

@ -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)
}

38
auth/token_source.go Normal file
View File

@ -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
View File

@ -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
View File

@ -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=