user authentication structure in place
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"zomo.dev/largehadroncollider/ttv"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (server *ApiServer) loadEndpoints() {
|
func (server *ApiServer) loadEndpoints() {
|
||||||
@@ -51,26 +53,42 @@ func (server *ApiServer) loadEndpoints() {
|
|||||||
|
|
||||||
server.engine.GET("/auth", func(c *gin.Context) {
|
server.engine.GET("/auth", func(c *gin.Context) {
|
||||||
q := c.Request.URL.Query()
|
q := c.Request.URL.Query()
|
||||||
if resp := loadAuthQueryOk(q); resp != nil {
|
if reqBody := loadAuthQueryOk(q); reqBody != nil {
|
||||||
// ok
|
// ok
|
||||||
// TODO check state (need state system)
|
|
||||||
// TODO POST https://id.twitch.tv/oauth2/token - returns TwitchAuthTokenResp
|
// TODO check state value (need state system)
|
||||||
// convert expiresIn to time.Time (minus like 15 minutes as a buffer period)
|
|
||||||
// UpdateUserAuth()
|
authResp, err := server.twitch.Auth.DoAuth(server.conf, reqBody.Code)
|
||||||
// TODO return twitch ok (or err if can't POST)
|
if err != nil {
|
||||||
} else if resp := loadAuthQueryErr(q); resp != nil {
|
log.Println("Error Authenticating:")
|
||||||
|
log.Printf(" Parsed Request Body: %+v", reqBody)
|
||||||
|
log.Printf(" Authorization Error: %v", err)
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = server.twitch.Auth.UpdateUserDetails(server.db, authResp.AccessToken, authResp.RefreshToken, authResp.ExpiresIn)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error Updating User Details:")
|
||||||
|
log.Printf(" Parsed Request Body: %+v", reqBody)
|
||||||
|
log.Printf(" Authorization Response: %+v", authResp)
|
||||||
|
log.Printf(" Error: %v", err)
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO return twitch ok
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
} else if reqBody := loadAuthQueryErr(q); reqBody != nil {
|
||||||
// err from twitch
|
// err from twitch
|
||||||
// TODO check state (need state system)
|
// TODO check state (need state system)
|
||||||
// TODO return twitch err
|
// TODO return twitch err
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
} else {
|
} else {
|
||||||
// err in params
|
// err in params
|
||||||
// TODO return param err
|
// TODO return param err
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO auth response from twitch
|
|
||||||
// parse args as TwitchAuthRespOk or TwitchAuthRespErr
|
|
||||||
// verify state with db and client id with config
|
|
||||||
c.JSON(http.StatusOK, serverInfo)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,9 +106,9 @@ type TwitchAuthParams struct {
|
|||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadAuthQueryOk(query url.Values) *TwitchAuthRespOk {
|
func loadAuthQueryOk(query url.Values) *ttv.TwitchAuthRespOk {
|
||||||
if query.Has("code") && query.Has("scope") && query.Has("state") {
|
if query.Has("code") && query.Has("scope") && query.Has("state") {
|
||||||
return &TwitchAuthRespOk{
|
return &ttv.TwitchAuthRespOk{
|
||||||
Code: query.Get("code"),
|
Code: query.Get("code"),
|
||||||
Scope: query.Get("scope"),
|
Scope: query.Get("scope"),
|
||||||
State: query.Get("state"),
|
State: query.Get("state"),
|
||||||
@@ -99,9 +117,9 @@ func loadAuthQueryOk(query url.Values) *TwitchAuthRespOk {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadAuthQueryErr(query url.Values) *TwitchAuthRespErr {
|
func loadAuthQueryErr(query url.Values) *ttv.TwitchAuthRespErr {
|
||||||
if query.Has("error") && query.Has("error_description") && query.Has("state") {
|
if query.Has("error") && query.Has("error_description") && query.Has("state") {
|
||||||
return &TwitchAuthRespErr{
|
return &ttv.TwitchAuthRespErr{
|
||||||
Err: query.Get("error"),
|
Err: query.Get("error"),
|
||||||
ErrDesc: query.Get("error_description"),
|
ErrDesc: query.Get("error_description"),
|
||||||
State: query.Get("state"),
|
State: query.Get("state"),
|
||||||
@@ -109,23 +127,3 @@ func loadAuthQueryErr(query url.Values) *TwitchAuthRespErr {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type TwitchAuthRespOk struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
State string `json:"state"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TwitchAuthRespErr struct {
|
|
||||||
Err string `json:"error"`
|
|
||||||
ErrDesc string `json:"error_description"`
|
|
||||||
State string `json:"state"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TwitchAuthTokenResp struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
ExpiresIn int `json:"expires_in"`
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
Scope []string `json:"scope"`
|
|
||||||
TokenType string `json:"token_type"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
func InitApiServer(conf *util.Config, dbConn *db.DBConn, twitchConn *ttv.TwitchConn) (*ApiServer, error) {
|
func InitApiServer(conf *util.Config, dbConn *db.DBConn, twitchConn *ttv.TwitchConn) (*ApiServer, error) {
|
||||||
engine := gin.Default()
|
engine := gin.Default()
|
||||||
|
|
||||||
apiServer := &ApiServer{ engine, conf, dbConn, twitchConn }
|
apiServer := &ApiServer{engine, conf, dbConn, twitchConn}
|
||||||
|
|
||||||
apiServer.loadEndpoints()
|
apiServer.loadEndpoints()
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package db_cold
|
package db_cold
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
|
"reflect"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const EXPIRATION_BUFFER = 5 * time.Minute
|
||||||
|
|
||||||
type UserAuth struct {
|
type UserAuth struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
UserID string `gorm:"primarykey"`
|
UserID string `gorm:"primarykey"`
|
||||||
@@ -31,7 +35,7 @@ func (db *DBColdConn) GetAllUserAuth() ([]UserAuth, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// add or update user auth, based on ID
|
// add or update user auth, based on ID
|
||||||
func (db *DBColdConn) UpdateUserAuth(userID, userName, userLogin, accessToken, refreshToken string, tokenExpires time.Time) error {
|
func (db *DBColdConn) updateUserAuthTime(userID, userName, userLogin, accessToken, refreshToken string, tokenExpires time.Time) error {
|
||||||
userAuth := UserAuth{
|
userAuth := UserAuth{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
UserName: userName,
|
UserName: userName,
|
||||||
@@ -62,3 +66,36 @@ func (db *DBColdConn) UpdateUserAuth(userID, userName, userLogin, accessToken, r
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DBColdConn) updateUserAuthDuration(userID, userName, userLogin, accessToken, refreshToken string, tokenExpires time.Duration) error {
|
||||||
|
return db.updateUserAuthTime(userID, userName, userLogin, accessToken, refreshToken, time.Now().Add(tokenExpires-EXPIRATION_BUFFER))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DBColdConn) updateUserAuthInt(userID, userName, userLogin, accessToken, refreshToken string, tokenExpires int) error {
|
||||||
|
return db.updateUserAuthDuration(userID, userName, userLogin, accessToken, refreshToken, time.Duration(tokenExpires)*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tokenExpires: time.Time | time.Duration | int */
|
||||||
|
func (db *DBColdConn) UpdateUserAuth(userID, userName, userLogin, accessToken, refreshToken string, tokenExpires any) error {
|
||||||
|
/*
|
||||||
|
expires can be:
|
||||||
|
- time.Time
|
||||||
|
- passed directly
|
||||||
|
- time.Duration
|
||||||
|
- converted to time.Time from now
|
||||||
|
- int
|
||||||
|
- converted to time.Duration in seconds then time.Time from now
|
||||||
|
*/
|
||||||
|
|
||||||
|
switch exp := tokenExpires.(type) {
|
||||||
|
case time.Time:
|
||||||
|
return db.updateUserAuthTime(userID, userName, userLogin, accessToken, refreshToken, exp)
|
||||||
|
case time.Duration:
|
||||||
|
return db.updateUserAuthDuration(userID, userName, userLogin, accessToken, refreshToken, exp)
|
||||||
|
case int:
|
||||||
|
return db.updateUserAuthInt(userID, userName, userLogin, accessToken, refreshToken, exp)
|
||||||
|
default:
|
||||||
|
log.Panicf("invalid type passed to any field in TwitchAuth.updateUserDetails(): %s, value: %v", reflect.TypeOf(tokenExpires), tokenExpires)
|
||||||
|
panic("we've already panicked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func InitDBColdConn(conf *util.Config) (*DBColdConn, error) {
|
|||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
cold := &DBColdConn{ db, ctx }
|
cold := &DBColdConn{db, ctx}
|
||||||
cold.initUserAuth()
|
cold.initUserAuth()
|
||||||
|
|
||||||
return cold, nil
|
return cold, nil
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func InitDBConn(conf *util.Config) (*DBConn, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &DBConn{ hot, cold }, nil
|
return &DBConn{hot, cold}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type DBConn struct {
|
type DBConn struct {
|
||||||
|
|||||||
12
test/main.go
Normal file
12
test/main.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
now := time.Now()
|
||||||
|
fmt.Println("Now:", now)
|
||||||
|
fmt.Println("Truncated:", now.Truncate(5*time.Minute).Add(5*time.Minute).Sub(now))
|
||||||
|
}
|
||||||
101
ttv/auth.go
101
ttv/auth.go
@@ -2,83 +2,96 @@ package ttv
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"github.com/adeithe/go-twitch/api"
|
"github.com/adeithe/go-twitch/api"
|
||||||
"zomo.dev/largehadroncollider/db"
|
"zomo.dev/largehadroncollider/db"
|
||||||
"zomo.dev/largehadroncollider/db/db_cold"
|
|
||||||
"zomo.dev/largehadroncollider/util"
|
"zomo.dev/largehadroncollider/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sign in to twitch with each saved tokens
|
const TWITCH_AUTH_URL = "https://id.twitch.tv/oauth2/token"
|
||||||
|
|
||||||
func initAuth(conf *util.Config, dbConn *db.DBConn) (*TwitchAuth, error) {
|
func initAuth(conf *util.Config, dbConn *db.DBConn) (*TwitchAuth, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
tokens, err := dbConn.Cold.GetAllUserAuth()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
client := api.New(conf.ClientID)
|
client := api.New(conf.ClientID)
|
||||||
|
twitchAuth := &TwitchAuth{ctx, client}
|
||||||
|
|
||||||
accounts, err := testTokens(ctx, client, tokens)
|
// run once synchronously then start looping in a thread
|
||||||
if err != nil {
|
twitchAuth.updateDetailsRefreshTokens(conf, dbConn)
|
||||||
return nil, err
|
go twitchAuth.loopUpdateDetailsRefreshTokens(conf, dbConn)
|
||||||
}
|
|
||||||
|
|
||||||
for _, account := range accounts {
|
return twitchAuth, nil
|
||||||
err := dbConn.Cold.UpdateUserAuth(account.UserID, account.UserName, account.UserLogin, account.AccessToken, account.RefreshToken, account.TokenExpires)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TwitchAuth{ ctx, client, accounts }, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TwitchAuth struct {
|
type TwitchAuth struct {
|
||||||
Ctx context.Context
|
Ctx context.Context
|
||||||
Client *api.Client
|
Client *api.Client
|
||||||
Accounts []db_cold.UserAuth
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTokens(ctx context.Context, client *api.Client, tokens []db_cold.UserAuth) ([]db_cold.UserAuth, error) {
|
func (twitch *TwitchAuth) doAuth(formData url.Values) (TwitchAuthTokenResp, error) {
|
||||||
accounts := make([]db_cold.UserAuth, 0)
|
resp, err := http.PostForm(TWITCH_AUTH_URL, formData)
|
||||||
for _, token := range tokens {
|
|
||||||
account, err := testToken(ctx, client, token)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return TwitchAuthTokenResp{}, err
|
||||||
}
|
}
|
||||||
accounts = append(accounts, account)
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return TwitchAuthTokenResp{}, err
|
||||||
}
|
}
|
||||||
return accounts, nil
|
|
||||||
|
var authResp TwitchAuthTokenResp
|
||||||
|
err = json.Unmarshal(bodyBytes, &authResp)
|
||||||
|
if err != nil {
|
||||||
|
return TwitchAuthTokenResp{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return authResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func testToken(ctx context.Context, client *api.Client, token db_cold.UserAuth) (db_cold.UserAuth, error) {
|
func (twitch *TwitchAuth) DoAuth(conf *util.Config, code string) (TwitchAuthTokenResp, error) {
|
||||||
// TODO check refresh time, refresh token if needed
|
formData := url.Values{
|
||||||
|
"client_id": {conf.ClientID},
|
||||||
|
"client_secret": {conf.ClientSecret},
|
||||||
|
"redirect_uri": {conf.RedirectURI},
|
||||||
|
"code": {code},
|
||||||
|
"grant_type": {"authorization_code"},
|
||||||
|
}
|
||||||
|
|
||||||
users, err := client.Users.List().Do(ctx, api.WithBearerToken(token.AccessToken))
|
return twitch.doAuth(formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (twitch *TwitchAuth) DoRefresh(conf *util.Config, refreshToken string) (TwitchAuthTokenResp, error) {
|
||||||
|
formData := url.Values{
|
||||||
|
"client_id": {conf.ClientID},
|
||||||
|
"client_secret": {conf.ClientSecret},
|
||||||
|
"refresh_token": {refreshToken},
|
||||||
|
"grant_type": {"refresh_token"},
|
||||||
|
}
|
||||||
|
|
||||||
|
return twitch.doAuth(formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (twitch *TwitchAuth) GetTokenUser(accessToken string) (api.User, error) {
|
||||||
|
return getTokenUser(twitch.Ctx, twitch.Client, accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTokenUser(ctx context.Context, client *api.Client, accessToken string) (api.User, error) {
|
||||||
|
users, err := client.Users.List().Do(ctx, api.WithBearerToken(accessToken))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return db_cold.UserAuth{}, err
|
return api.User{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
usersData := users.Data
|
usersData := users.Data
|
||||||
|
|
||||||
if len(usersData) <= 0 {
|
if len(usersData) <= 0 {
|
||||||
return db_cold.UserAuth{}, errors.New("user data returned an empty array")
|
return api.User{}, errors.New("user data returned an empty array")
|
||||||
}
|
}
|
||||||
|
|
||||||
// from twitch
|
// from twitch
|
||||||
mainUser := usersData[0]
|
return usersData[0], nil
|
||||||
token.UserLogin = mainUser.UserLogin
|
|
||||||
token.UserName = mainUser.UserName
|
|
||||||
token.UserEmail = mainUser.Email
|
|
||||||
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshToken(token db_cold.UserAuth) (db_cold.UserAuth, error) {
|
|
||||||
// TODO get new access token using refresh token
|
|
||||||
// TODO this should be called regularly, as needed based on Expires
|
|
||||||
}
|
}
|
||||||
|
|||||||
69
ttv/expiration.go
Normal file
69
ttv/expiration.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package ttv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"zomo.dev/largehadroncollider/db"
|
||||||
|
"zomo.dev/largehadroncollider/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL = 5 * time.Minute
|
||||||
|
|
||||||
|
// token expiration thread
|
||||||
|
|
||||||
|
func (twitch *TwitchAuth) loopUpdateDetailsRefreshTokens(conf *util.Config, dbConn *db.DBConn) {
|
||||||
|
for {
|
||||||
|
// sleep until next interval
|
||||||
|
now := time.Now()
|
||||||
|
nextTime := now.Truncate(REFRESH_INTERVAL).Add(REFRESH_INTERVAL)
|
||||||
|
time.Sleep(nextTime.Sub(now))
|
||||||
|
|
||||||
|
// check tokens
|
||||||
|
err := twitch.updateDetailsRefreshTokens(conf, dbConn)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error Updating Tokens: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (twitch *TwitchAuth) updateDetailsRefreshTokens(conf *util.Config, dbConn *db.DBConn) error {
|
||||||
|
now := time.Now().Add(REFRESH_INTERVAL)
|
||||||
|
|
||||||
|
tokens, err := dbConn.Cold.GetAllUserAuth()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, token := range tokens {
|
||||||
|
if token.TokenExpires.Before(now) {
|
||||||
|
// refresh and update details
|
||||||
|
authResp, err := twitch.DoRefresh(conf, token.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = twitch.UpdateUserDetails(dbConn, authResp.AccessToken, authResp.RefreshToken, authResp.ExpiresIn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// only update details
|
||||||
|
err = twitch.UpdateUserDetails(dbConn, token.AccessToken, token.RefreshToken, token.TokenExpires)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tokenExpires: time.Time | time.Duration | int */
|
||||||
|
func (twitch *TwitchAuth) UpdateUserDetails(dbConn *db.DBConn, accessToken, refreshToken string, tokenExpires any) error {
|
||||||
|
user, err := twitch.GetTokenUser(accessToken)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbConn.Cold.UpdateUserAuth(user.UserID, user.UserName, user.UserLogin, accessToken, refreshToken, tokenExpires)
|
||||||
|
}
|
||||||
22
ttv/main.go
22
ttv/main.go
@@ -11,9 +11,29 @@ func InitTwitchConn(conf *util.Config, dbConn *db.DBConn) (*TwitchConn, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &TwitchConn{ auth }, nil
|
return &TwitchConn{auth}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type TwitchConn struct {
|
type TwitchConn struct {
|
||||||
Auth *TwitchAuth
|
Auth *TwitchAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TwitchAuthRespOk struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TwitchAuthRespErr struct {
|
||||||
|
Err string `json:"error"`
|
||||||
|
ErrDesc string `json:"error_description"`
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TwitchAuthTokenResp struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
Scope []string `json:"scope"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user