From 43e48e7e265ea948745a930a171385f9fd3d5e86 Mon Sep 17 00:00:00 2001 From: Jack McGuire Date: Tue, 27 Jul 2021 16:41:27 +0100 Subject: [PATCH] PROPOSAL: helix extension endpoints - JWT signing - extension configuration segments endpoints - extension secrets endpoints - UPDATE extension docs --- README.md | 10 +-- docs/extensions_docs.md | 93 ++++++++++++++++++++++ extension_configuration.go | 97 +++++++++++++++++++++++ extension_jwt.go | 155 +++++++++++++++++++++++++++++++++++++ extension_pubsub.go | 76 ++++++++++++++++++ extension_secrets.go | 64 +++++++++++++++ extensions.go | 24 ++++++ go.mod | 2 + go.sum | 2 + helix.go | 23 ++++++ main.go | 1 + 11 files changed, 542 insertions(+), 5 deletions(-) create mode 100644 extension_configuration.go create mode 100644 extension_jwt.go create mode 100644 extension_pubsub.go create mode 100644 extension_secrets.go create mode 100644 go.sum create mode 100644 main.go diff --git a/README.md b/README.md index cb61264..648b330 100644 --- a/README.md +++ b/README.md @@ -109,12 +109,12 @@ If you are looking for the Twitch API docs, see the [Twitch Developer website](h - [ ] Get Extension Secret - [ ] Revoke Extension Secrets - [ ] Get Live Channels with Extension Activated -- [ ] Set Extension Required Configuration -- [ ] Set Extension Configuration Segment +- [x] Set Extension Required Configuration +- [x] Set Extension Configuration Segment - [ ] Get Extension Channel Configuration -- [ ] Get Extension Configuration Segment -- [ ] Send Extension PubSub Message -- [ ] Send Extension Chat Message +- [x] Get Extension Configuration Segment +- [x] Send Extension PubSub Message +- [x] Send Extension Chat Message ## Quick Usage Example diff --git a/docs/extensions_docs.md b/docs/extensions_docs.md index f6ade86..045e92b 100644 --- a/docs/extensions_docs.md +++ b/docs/extensions_docs.md @@ -1,5 +1,98 @@ # Extensions Documentation +## Extension Helix Requests + +#### Generate PUBSUB JWT Permissions +> relevant PUBSUB permission must be passed to the 'ExtensionCreateClaims()' func, in order to correctly publish a pubsub message of a particular type + +Broadcast pubsub type +```go +client.FormBroadcastSendPubSubPermissions() +``` + +Global pubsub type +```go +perms := client.FormGlobalSendPubSubPermissions() +``` + +Whisper User type +```go +client.FormWhisperSendPubSubPermissions(userId) +``` + +### JWT ROLES +> Note:- Currently only the 'external' role is supported by helix endpoints + + +### EBS JWT +this is used to set the correct header for any Extension helix requests + +```go +client, err := helix.NewClient(&helix.Options{ + ClientID: "your-client-id", + UserAccessToken: "your-user-access-token", + ExtensionOpts: helix.ExtensionOptions{ + OwnerUserID: os.Getenv(""), + Secret: os.Getenv(""), + ConfigurationVersion: os.Getenv(""), + Version: os.Getenv(""), + }, +}) + + +// see docs below to see what permissions and roles you can pass +claims, err := client.ExtensionCreateClaims(broadcasterID, ExternalRole, client.FormBroadcastSendPubSubPermissions(), 0) +if err != nil { + // handle err +} + +jwt,err := client.ExtensionJWTSign(claims) +if err != nil { + // handle err +} + +// set this before doing extension endpoint requests +client.SetExtensionSignedJWTToken(jwt) +``` +## Get Extension Configuration Segments + +```go + +client, err := helix.NewClient(&helix.Options{ + ClientID: "your-client-id", + UserAccessToken: "your-user-access-token", + ExtensionOpts: helix.ExtensionOptions{ + OwnerUserID: os.Getenv("EXT_OWNER_ID"), + Secret: os.Getenv("EXT_SECRET"), + ConfigurationVersion: os.Getenv("EXT_CFG_VERSION"), + Version: os.Getenv("EXT_VERSION"), + }, +}) +if err != nil { + // handle error +} + +claims, err := client.ExtensionCreateClaims(broadcasterID, ExternalRole, FormBroadcastSendPubSubPermissions(), 0) +if err != nil { + // handle error +} + +// set the JWT token to be used as in the Auth bearer header +jwt := client.ExtensionJWTSign(claims) +client.SetExtensionSignedJWTToken(jwt) + +params := helix.ExtensionGetConfigurationParams{ + ExtensionID: "some-extension-id", // Required + Segments: []helix.ExtensionSegmentType{helix.GlobalSegment}, // Optional +} +resp, err := client.GetExtensionConfigurationSegment +if err != nil { + // handle error +} + +fmt.Printf("%+v\n", resp) +``` + ## Get Extension Transactions ```go diff --git a/extension_configuration.go b/extension_configuration.go new file mode 100644 index 0000000..af14279 --- /dev/null +++ b/extension_configuration.go @@ -0,0 +1,97 @@ +package helix + +// SegmentType A segment configuration type +type ExtensionSegmentType string + +// Types of segments datastores for the configuration service +const ( + BroadcasterSegment ExtensionSegmentType = "broadcaster" + DeveloperSegment ExtensionSegmentType = "developer" + GlobalSegment ExtensionSegmentType = "global" +) + +func (s ExtensionSegmentType) String() string { + return string(s) +} + +type ExtensionConfigurationParams struct { + Segment ExtensionSegmentType `json:"segment"` + ExtensionId string `json:"extension-id"` + Version string `json:"version"` + Content string `json:"content"` +} + +type ExtensionConfigurationSegment struct { + Segment ExtensionSegmentType `json:"segment"` + BroadcasterID string `json:"broadcaster_id,omitempty"` // populated if segment is of type 'developer' || 'broadcaster' + Version string `json:"version"` + Content string `json:"content"` +} + +type ExtensionGetConfigurationParams struct { + ExtensionID string `query:"extension_id"` + Segment []ExtensionSegmentType `query:"segment"` +} + +type ExtensionSetRequiredConfigurationParams struct { + BroadcasterID string `json:"-" query:"broadcaster_id"` + ExtensionID string `json:"extension_id"` + ExtensionVersion string `json:"extension_version"` + RequiredConfiguration string `json:"required_configuration"` +} + +type ExtensionSetRequiredConfigurationResponse struct { + ResponseCommon +} + +type ExtensionGetConfigurationSegmentResponse struct { + ResponseCommon + Data ManyExtensionConfigurationSegments +} + +type ManyExtensionConfigurationSegments struct { + Segments []ExtensionConfigurationSegment +} + +type ExtensionSetConfigurationResponse struct { + ResponseCommon +} + +// https://dev.twitch.tv/docs/extensions/reference/#set-extension-configuration-segment +func (c *Client) SetExtensionSegmentConfig(params *ExtensionConfigurationParams) (*ExtensionSetConfigurationResponse, error) { + resp, err := c.putAsJSON("/extensions/configurations", &ManyPolls{}, params) + if err != nil { + return nil, err + } + + setExtCnfgResp := &ExtensionSetConfigurationResponse{} + resp.HydrateResponseCommon(&setExtCnfgResp.ResponseCommon) + + return setExtCnfgResp, nil +} + +func (c *Client) GetExtensionConfigurationSegment(params *ExtensionGetConfigurationParams) (*ExtensionGetConfigurationSegmentResponse, error) { + resp, err := c.get("/extensions/configurations", &ManyExtensionConfigurationSegments{}, params) + if err != nil { + return nil, err + } + + extCfgSegResp := &ExtensionGetConfigurationSegmentResponse{} + resp.HydrateResponseCommon(&extCfgSegResp.ResponseCommon) + extCfgSegResp.Data.Segments = resp.Data.(*ManyExtensionConfigurationSegments).Segments + + return extCfgSegResp, nil +} + +func (c *Client) SetExtensionRequiredConfiguration(params *ExtensionSetRequiredConfigurationParams) (*ExtensionSetRequiredConfigurationResponse, error) { + + resp, err := c.putAsJSON("/extensions/configurations/required_configuration", &ExtensionSetRequiredConfigurationResponse{}, params) + if err != nil { + return nil, err + } + + extReqCfgResp := &ExtensionSetRequiredConfigurationResponse{} + resp.HydrateResponseCommon(&extReqCfgResp.ResponseCommon) + + return extReqCfgResp, nil +} diff --git a/extension_jwt.go b/extension_jwt.go new file mode 100644 index 0000000..f52e557 --- /dev/null +++ b/extension_jwt.go @@ -0,0 +1,155 @@ +package helix + +import ( + "encoding/base64" + "fmt" + "time" + + "github.com/dgrijalva/jwt-go" +) + +// RoleType The user role type +type roleType string + +// Types of user roles used within the JWT Claims +// TODO expose these when helix supports them +const ( + BroadcasterRole roleType = "broadcaster" + ExternalRole roleType = "external" + ModeratorRole roleType = "moderator" + ViewerRole roleType = "viewer" + + toAllChannels = "all" +) + +// PubSubPermissions publish permissions used within +// JWT claims +type PubSubPermissions struct { + Send []ExtensionPubSubPublishType `json:"send,omitempty"` + Listen []ExtensionPubSubPublishType `json:"listen,omitempty"` +} + +// TwitchJWTClaims contains information +// containing twitch specific JWT information. +type TwitchJWTClaims struct { + OpaqueUserID string `json:"opaque_user_id,omitempty"` + UserID string `json:"user_id"` + ChannelID string `json:"channel_id,omitempty"` + Role roleType `json:"role"` + Unlinked bool `json:"is_unlinked,omitempty"` + Permissions *PubSubPermissions `json:"pubsub_perms"` + jwt.StandardClaims +} + +// CreateClaims will construct a claims suitable for generating a JWT token, +// containing necessary information required by the Twitch API. +// @param BroadcasterID if this value is empty it will default to 'all' +// @param pubsub the pubsub permission to attach to the claim +// @param expiration the epoch of jwt expiration, default 3 minutes from time.Now +func (c *Client) ExtensionCreateClaims( + broadcasterID string, + pubsub *PubSubPermissions, + expiration int64, +) ( + *TwitchJWTClaims, + error, +) { + err := c.validateExtensionOpts() + if err != nil { + return nil, err + } + + // default expiration to 3 minutes + if expiration == 0 { + expiration = time.Now().Add(time.Minute*3).UnixNano() / int64(time.Millisecond) + } + + if broadcasterID == "" { + broadcasterID = toAllChannels + } + + claims := &TwitchJWTClaims{ + UserID: c.opts.ExtensionOpts.OwnerUserID, + ChannelID: broadcasterID, + Role: ExternalRole, + Permissions: pubsub, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: expiration, + }, + } + + return claims, nil +} + +// ExtensionJWTSign Sign the a JWT Claim to produce a base64 token. +func (c *Client) ExtensionJWTSign(claims *TwitchJWTClaims) (tokenString string, err error) { + + err = c.validateExtensionOpts() + if err != nil { + return "", err + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + key, err := base64.StdEncoding.DecodeString(c.opts.ExtensionOpts.Secret) + if err != nil { + return + } + + tokenString, err = token.SignedString(key) + if err != nil { + return + } + + return +} + +// ExtensionJWTVerify validates a extension client side twitch base64 token and converts it +// into a twitch claim type, containing relevant information. +func (c *Client) ExtensionJWTVerify(token string) (claims *TwitchJWTClaims, err error) { + if token == "" { + err = fmt.Errorf("JWT token string missing") + return + } + + err = c.validateExtensionOpts() + if err != nil { + return nil, err + } + + parsedToken, err := jwt.ParseWithClaims(token, &TwitchJWTClaims{}, func(tkn *jwt.Token) (interface{}, error) { + if _, ok := tkn.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("Unexpected signing method: %s", tkn.Header["alg"]) + } + + key, err := base64.StdEncoding.DecodeString(c.opts.ExtensionOpts.Secret) + + if err != nil { + return nil, err + } + return key, nil + }) + if err != nil { + return + } + + claims, ok := parsedToken.Claims.(*TwitchJWTClaims) + if !ok || !parsedToken.Valid { + err = fmt.Errorf("could not parse JWT") + return + } + + return +} + +func (c *Client) validateExtensionOpts() error { + if c.opts.ExtensionOpts.OwnerUserID == "" { + return fmt.Errorf("extension secret is empty") + } + + if c.opts.ExtensionOpts.Secret == "" { + return fmt.Errorf("extension secret is empty") + } + + return nil +} diff --git a/extension_pubsub.go b/extension_pubsub.go new file mode 100644 index 0000000..f44b187 --- /dev/null +++ b/extension_pubsub.go @@ -0,0 +1,76 @@ +package helix + +// PublishType The Pub/Sub broadcast type +type ExtensionPubSubPublishType string + +type pubSubNotification struct { + Message string `json:"message"` + Targets []ExtensionPubSubPublishType `json:"targets"` + ContentType string `json:"content_type"` +} + +// Types of Pub/Sub Permissions or targets +const ( + ExtensionPubSubGenericPublish ExtensionPubSubPublishType = "*" + ExtensionPubSubBroadcastPublish ExtensionPubSubPublishType = "broadcast" + ExtensionPubSubGlobalPublish ExtensionPubSubPublishType = "global" +) + +func (c *Client) createExtensionPubSubWhisper(opaqueId string) ExtensionPubSubPublishType { + return ExtensionPubSubPublishType("whisper-" + opaqueId) +} + +// FormWhisperSendPubSubPermissions create the pubsub permissions +// for publishing a whisper message type +func (c *Client) FormWhisperSendPubSubPermissions(opaqueId string) *PubSubPermissions { + return &PubSubPermissions{ + Send: []ExtensionPubSubPublishType{c.createExtensionPubSubWhisper(opaqueId)}, + } +} + +// FormBroadcastSendPubSubPermissions create the pubsub permissions +// for publishing a broadcast message type +func (c *Client) FormBroadcastSendPubSubPermissions() *PubSubPermissions { + return &PubSubPermissions{ + Send: []ExtensionPubSubPublishType{ExtensionPubSubBroadcastPublish}, + } +} + +// FormGlobalSendPubSubPermissions create the pubsub permissions +// for publishing a global targeted message +func FormGlobalSendPubSubPermissions() *PubSubPermissions { + return &PubSubPermissions{ + Send: []ExtensionPubSubPublishType{ExtensionPubSubGlobalPublish}, + } +} + +// FormGenericPubSubPermissions create the pubsub permissions +// for publishing to message for any target type +func FormGenericPubSubPermissions() *PubSubPermissions { + return &PubSubPermissions{ + Send: []ExtensionPubSubPublishType{ExtensionPubSubGenericPublish}, + } +} + +type SendExtensionPubSubMessageParams struct { + BroadcasterID string `json:"broadcaster_id"` + Message string `json:"message"` + Target string `json:"target"` + IsGlobalBroadcast bool `json:"is_global_broadcast"` +} + +type SendExtensionPubSubMessageResponse struct { + ResponseCommon +} + +func (c *Client) SendExtensionPubSubMessage(params *SendExtensionPubSubMessageParams) (*SendExtensionPubSubMessageResponse, error) { + resp, err := c.postAsJSON("/extensions/pubsub", &SendExtensionPubSubMessageResponse{}, params) + if err != nil { + return nil, err + } + + sndExtPubSubMsgRsp := &SendExtensionPubSubMessageResponse{} + resp.HydrateResponseCommon(&sndExtPubSubMsgRsp.ResponseCommon) + + return sndExtPubSubMsgRsp, nil +} diff --git a/extension_secrets.go b/extension_secrets.go new file mode 100644 index 0000000..aff20bb --- /dev/null +++ b/extension_secrets.go @@ -0,0 +1,64 @@ +package helix + +// GetExtensionSecretResponse response structure received +// when generating or querying for generated secrets +type ExtensionSecretCreationResponse struct { + Data ManyExtensionSecrets + ResponseCommon +} + +// GetExtensionSecretResponse response structure received +// when fetching secrets for an extension +type GetExtensionSecretResponse struct { + Data ManyExtensionSecrets + ResponseCommon +} + +type ManyExtensionSecrets struct { + Version int `json:"format_version"` + Secrets []Secret `json:"secrets"` +} + +// Secret information about a generated secret +type Secret struct { + ActiveAt Time `json:"active_at"` + Content string `json:"content"` + Expires Time `json:"expires_at"` +} + +type ExtensionSecretCreationParams struct { + ActivationDelay int `query:"delay,300"` // min 300 + ExtensionID string `query:"extension_id"` +} + +type GetExtensionSecretParams struct { + ExtensionID string `query:"extension_id"` +} + +func (c *Client) CreateExtensionSecret(params *ExtensionSecretCreationParams) (*ExtensionSecretCreationResponse, error) { + resp, err := c.post("/extensions/jwt/secrets", &ManyExtensionSecrets{}, params) + if err != nil { + return nil, err + } + + events := &ExtensionSecretCreationResponse{} + resp.HydrateResponseCommon(&events.ResponseCommon) + events.Data.Secrets = resp.Data.(*ManyExtensionSecrets).Secrets + events.Data.Version = resp.Data.(*ManyExtensionSecrets).Version + + return events, nil +} + +func (c *Client) GetExtensionSecret(params *GetExtensionSecretParams) (*GetExtensionSecretResponse, error) { + resp, err := c.postAsJSON("/extensions/jwt/secrets", &ManyExtensionSecrets{}, params) + if err != nil { + return nil, err + } + + events := &GetExtensionSecretResponse{} + resp.HydrateResponseCommon(&events.ResponseCommon) + events.Data.Secrets = resp.Data.(*ManyExtensionSecrets).Secrets + events.Data.Version = resp.Data.(*ManyExtensionSecrets).Version + + return events, nil +} diff --git a/extensions.go b/extensions.go index 86603d2..c7983e5 100644 --- a/extensions.go +++ b/extensions.go @@ -41,6 +41,17 @@ type ExtensionTransactionsParams struct { First int `query:"first,20"` // Optional, Limit 100 } +type SendExtensionMessageParams struct { + BroadcasterID string `query:"broadcaster_id" json:"-"` + Text string `json:"text"` + Version string `json:"version"` + ExtensionID string `json:"extension_id"` +} + +type SendExtensionMessageResponse struct { + ResponseCommon +} + // GetExtensionTransactions allows extension back end servers to fetch a list of transactions that // have occurred for their extension across all of Twitch. A transaction is a record of a user // exchanging Bits for an in-Extension digital good. @@ -58,3 +69,16 @@ func (c *Client) GetExtensionTransactions(params *ExtensionTransactionsParams) ( extTxnResp.Data.Pagination = resp.Data.(*ManyExtensionTransactions).Pagination return extTxnResp, nil } + +func (c *Client) SendExtensionChatMessage(params *SendExtensionMessageParams) (*SendExtensionMessageResponse, error) { + + resp, err := c.postAsJSON("/extensions/chat", &SendExtensionMessageResponse{}, params) + if err != nil { + return nil, err + } + + sndExtMsgResp := &SendExtensionMessageResponse{} + resp.HydrateResponseCommon(&sndExtMsgResp.ResponseCommon) + + return sndExtMsgResp, nil +} diff --git a/go.mod b/go.mod index 45676f4..a92942f 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/nicklaw5/helix go 1.15 + +require github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6a8f140 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= diff --git a/helix.go b/helix.go index bb80a2e..52b2800 100644 --- a/helix.go +++ b/helix.go @@ -42,6 +42,15 @@ type Options struct { HTTPClient HTTPClient RateLimitFunc RateLimitFunc APIBaseURL string + ExtensionOpts ExtensionOptions +} + +type ExtensionOptions struct { + OwnerUserID string + Secret string + Version string + ConfigurationVersion string + SignedJWTToken string } // DateRange is a generic struct used by various responses. @@ -394,6 +403,9 @@ func (c *Client) setRequestHeaders(req *http.Request) { if opts.UserAccessToken != "" { bearerToken = opts.UserAccessToken } + if opts.ExtensionOpts.SignedJWTToken != "" { + bearerToken = opts.ExtensionOpts.SignedJWTToken + } authType := "Bearer" // Token validation requires different type of Auth @@ -434,6 +446,17 @@ func (c *Client) SetUserAccessToken(accessToken string) { c.opts.UserAccessToken = accessToken } +// GetAppAccessToken returns the current app access token. +func (c *Client) GetExtensionSignedJWTToken() string { + return c.opts.ExtensionOpts.SignedJWTToken +} + +func (c *Client) SetExtensionSignedJWTToken(jwt string) { + c.mu.Lock() + defer c.mu.Unlock() + c.opts.ExtensionOpts.SignedJWTToken = jwt +} + func (c *Client) SetUserAgent(userAgent string) { c.mu.Lock() defer c.mu.Unlock() diff --git a/main.go b/main.go new file mode 100644 index 0000000..afb1b43 --- /dev/null +++ b/main.go @@ -0,0 +1 @@ +package helix