From 09d6bbd7f4fbec58ca77374a5f6da47988297b86 Mon Sep 17 00:00:00 2001 From: Nick Harbinger Date: Fri, 31 May 2024 01:12:31 -0600 Subject: [PATCH] Added basic OAuth2 support moved oauth2.go and oauth2_test.go to applications.go and applications_test.go added basic OAuth2 endpoints to endpoints.go added util functions for creating 'Basic' token from client id and secret to util.go added util functions for prefixing 'Bearer' and 'Bot' tokens to util.go added methods to check token type on Sessions in structs.go added wrong token type errors to restapi.go changed RequestWithBucketID to use x-form-urlencoded encoding when given a url.Values data object to deal with discord's pendantic OAuth2 spec adherence created oauth2.go with basic OAuth2 types added ability to preform OAuth2 operation with a Session created from a 'Basic' token using client id and secret added ability to get OAuth2 authorization information for Session's created with 'Bearer' and 'Bot' tokens added godoc comments for above created a OAuth2 demo server in examples --- applications.go | 154 +++++++++ oauth2_test.go => applications_test.go | 0 endpoints.go | 4 + examples/oauth2/README.md | 77 +++++ examples/oauth2/main.go | 413 +++++++++++++++++++++++++ oauth2.go | 246 +++++++-------- restapi.go | 44 +-- structs.go | 21 +- util.go | 13 + 9 files changed, 832 insertions(+), 140 deletions(-) create mode 100644 applications.go rename oauth2_test.go => applications_test.go (100%) create mode 100644 examples/oauth2/README.md create mode 100644 examples/oauth2/main.go diff --git a/applications.go b/applications.go new file mode 100644 index 000000000..2fa2d8d54 --- /dev/null +++ b/applications.go @@ -0,0 +1,154 @@ +// Discordgo - Discord bindings for Go +// Available at https://github.com/bwmarrin/discordgo + +// Copyright 2015-2016 Bruce Marriner . All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This file contains functions related to Discord OAuth2 endpoints + +package discordgo + +// ------------------------------------------------------------------------------------------------ +// Code specific to Discord OAuth2 Applications +// ------------------------------------------------------------------------------------------------ + +// The MembershipState represents whether the user is in the team or has been invited into it +type MembershipState int + +// Constants for the different stages of the MembershipState +const ( + MembershipStateInvited MembershipState = 1 + MembershipStateAccepted MembershipState = 2 +) + +// A TeamMember struct stores values for a single Team Member, extending the normal User data - note that the user field is partial +type TeamMember struct { + User *User `json:"user"` + TeamID string `json:"team_id"` + MembershipState MembershipState `json:"membership_state"` + Permissions []string `json:"permissions"` +} + +// A Team struct stores the members of a Discord Developer Team as well as some metadata about it +type Team struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + OwnerID string `json:"owner_user_id"` + Members []*TeamMember `json:"members"` +} + +// Application returns an Application structure of a specific Application +// appID : The ID of an Application +func (s *Session) Application(appID string) (st *Application, err error) { + + body, err := s.RequestWithBucketID("GET", EndpointOAuth2Application(appID), nil, EndpointOAuth2Application("")) + if err != nil { + return + } + + err = unmarshal(body, &st) + return +} + +// Applications returns all applications for the authenticated user +func (s *Session) Applications() (st []*Application, err error) { + + body, err := s.RequestWithBucketID("GET", EndpointOAuth2Applications, nil, EndpointOAuth2Applications) + if err != nil { + return + } + + err = unmarshal(body, &st) + return +} + +// ApplicationCreate creates a new Application +// name : Name of Application / Bot +// uris : Redirect URIs (Not required) +func (s *Session) ApplicationCreate(ap *Application) (st *Application, err error) { + + data := struct { + Name string `json:"name"` + Description string `json:"description"` + }{ap.Name, ap.Description} + + body, err := s.RequestWithBucketID("POST", EndpointOAuth2Applications, data, EndpointOAuth2Applications) + if err != nil { + return + } + + err = unmarshal(body, &st) + return +} + +// ApplicationUpdate updates an existing Application +// var : desc +func (s *Session) ApplicationUpdate(appID string, ap *Application) (st *Application, err error) { + + data := struct { + Name string `json:"name"` + Description string `json:"description"` + }{ap.Name, ap.Description} + + body, err := s.RequestWithBucketID("PUT", EndpointOAuth2Application(appID), data, EndpointOAuth2Application("")) + if err != nil { + return + } + + err = unmarshal(body, &st) + return +} + +// ApplicationDelete deletes an existing Application +// appID : The ID of an Application +func (s *Session) ApplicationDelete(appID string) (err error) { + + _, err = s.RequestWithBucketID("DELETE", EndpointOAuth2Application(appID), nil, EndpointOAuth2Application("")) + if err != nil { + return + } + + return +} + +// Asset struct stores values for an asset of an application +type Asset struct { + Type int `json:"type"` + ID string `json:"id"` + Name string `json:"name"` +} + +// ApplicationAssets returns an application's assets +func (s *Session) ApplicationAssets(appID string) (ass []*Asset, err error) { + + body, err := s.RequestWithBucketID("GET", EndpointOAuth2ApplicationAssets(appID), nil, EndpointOAuth2ApplicationAssets("")) + if err != nil { + return + } + + err = unmarshal(body, &ass) + return +} + +// ------------------------------------------------------------------------------------------------ +// Code specific to Discord OAuth2 Application Bots +// ------------------------------------------------------------------------------------------------ + +// ApplicationBotCreate creates an Application Bot Account +// +// appID : The ID of an Application +// +// NOTE: func name may change, if I can think up something better. +func (s *Session) ApplicationBotCreate(appID string) (st *User, err error) { + + body, err := s.RequestWithBucketID("POST", EndpointOAuth2ApplicationsBot(appID), nil, EndpointOAuth2ApplicationsBot("")) + if err != nil { + return + } + + err = unmarshal(body, &st) + return +} diff --git a/oauth2_test.go b/applications_test.go similarity index 100% rename from oauth2_test.go rename to applications_test.go diff --git a/endpoints.go b/endpoints.go index b2e632692..66a673ef4 100644 --- a/endpoints.go +++ b/endpoints.go @@ -209,6 +209,10 @@ var ( EndpointApplicationRoleConnectionMetadata = func(aID string) string { return EndpointApplication(aID) + "/role-connections/metadata" } EndpointOAuth2 = EndpointAPI + "oauth2/" + EndpointOAuth2Token = EndpointOAuth2 + "token" + EndpointOAuth2TokenRevoke = EndpointOAuth2Token + "/revoke" + EndpointOAuth2AuthorizationInfo = EndpointOAuth2 + "@me" + EndpointOAuth2Authorize = EndpointDiscord + "oauth2/authorize" EndpointOAuth2Applications = EndpointOAuth2 + "applications" EndpointOAuth2Application = func(aID string) string { return EndpointOAuth2Applications + "/" + aID } EndpointOAuth2ApplicationsBot = func(aID string) string { return EndpointOAuth2Applications + "/" + aID + "/bot" } diff --git a/examples/oauth2/README.md b/examples/oauth2/README.md new file mode 100644 index 000000000..c9ed9fcdf --- /dev/null +++ b/examples/oauth2/README.md @@ -0,0 +1,77 @@ +DiscordGo logo + +## DiscordGo OAuth2 Example + +This example demonstrates how to utilize DiscordGo to request discord tokens +from the discord oauth2 api using a demo. + +**Join [Discord Gophers](https://discord.gg/0f1SbxBZjYoCtNPP) +Discord chat channel for support.** + +### Build + +This assumes you already have a working Go environment setup and that +DiscordGo is correctly installed on your system. + +From within the airhorn example folder, run the command below to compile the +example. + +```sh +go build +``` + +### Usage + +``` +Usage of ./oauth2 + -id string + Client id taken from the Settings>OAuth2 section of your application on the Discord Developer's Portal + -secret string + Client secret taken from the Settings>OAuth2 section of your application on the Discord Developer's Portal + -redirect string + Redirect URI set in Settings>OAuth2 section of your application on the Discord Developers Portal + -scope string + Scope to use for all OAuth2 request made with the demo server + -port int + Port to bind the demo server must be a number between 1 and 65535 + -cert string + Full path to an ssl certificate if you wish the server to run in https mode + -key string + Full path to an ssl key if you wish the server to run in https mode + -log bool + Enable loggin response to stdout +``` + +### Setup + +You must have created an application in the [Discord Developer's Portal](https://discord.com/developers/applications) +and have setup the Settings>OAuth2 section there to use the demo server. + +You will need to grab both the client id and client secret from that section +as well as register a redirect uri to use in that section. + +### Http/s Mode + +The demo server will run in http mode unless it is provided with an +ssl certificate and key using the -cert and -key options. + +Both should be full path names and the cert file should be the full chain +cert file usually called "fullchain.pem" and not the individual certifcate file. + +### Using the demo server + +The demo server must be run on the same machine as the redirect uri points to. + +Assuming your redirect uri is `https://example.org/signin`: + +Go to `https://example.org/signin` and you will be redirected to Discord's +OAuth2 sign in portal. After approving the sign in you will be returned to +the demo server and your access token will be shown. + +Go to `https://example.org/signin?cred` and the demo server will obtain the +OAuth2 token for the application's developer. The tokens will be redacted in +the demo server's response but not the stdout log if enabled. + +Go to `https://example.org/signin?token=...`, substituting a valid access token +without the `Bearer ` prefix for ..., and the demo server will display the +current authorization info for that access token. diff --git a/examples/oauth2/main.go b/examples/oauth2/main.go new file mode 100644 index 000000000..afe4718ab --- /dev/null +++ b/examples/oauth2/main.go @@ -0,0 +1,413 @@ +package main + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/json" + "flag" + "log" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/bwmarrin/discordgo" +) + +// structure for holding session and settings for http server and oauth2 requests +type handler struct { + // Discord session for preforming oauth2 requests + session *discordgo.Session + // Discord application oauth2 client id + id string + /* Applications redirect uri registered with discord on the application/oauth2 + dev portal page + */ + uri *url.URL + // Whether log response to stdout + log bool + // What scope ( permissions ) to request from discord in oauth2 requests + scope string + // Cache of states used in oauth2 redirect + states map[string]int64 +} + +// ServeHTTP handler for go's http server +func( oauth2 handler ) ServeHTTP( response http.ResponseWriter, request *http.Request ) { + if request.URL.Path != oauth2.uri.Path { + response.Header().Set( "Content-Type", "text/plain" ) + response.WriteHeader( http.StatusNotFound ) + response.Write( []byte( + "Only the path in -redirect ( " + + oauth2.uri.Path + + " ) is valid for this test server" ) ) + return + } + + values := request.URL.Query( ) + + switch { + case values.Has( "state" ) || values.Has( "code" ): + oauth2.doAccessToken( response, request ) + + case values.Has( "token" ): + oauth2.doAuthorizationInfo( response, request ) + + case values.Has( "cred" ): + oauth2.doClientCredentials( response, request ) + + default: + // create a random 32 byte state and base64 encode it + var buffer = make( []byte, 32 ) + rand.Read( buffer ) + state := base64.URLEncoding.EncodeToString( buffer ) + + oauth2.states[ state ] = time.Now( ).UTC( ).Unix( ) + + location := + "https://discord.com/oauth2/authorize?" + + "client_id=" + oauth2.id + + "&response_type=code" + + "&redirect_uri=" + oauth2.uri.String( ) + + "&scope=" + oauth2.scope + + "&state=" + state + + response.Header( ).Set( "Location", location ) + response.WriteHeader( http.StatusTemporaryRedirect ) + } +} + +func ( oauth2 handler ) doAccessToken ( response http.ResponseWriter, request *http.Request ) { + code := request.URL.Query( ).Get( "code" ) + state := request.URL.Query( ).Get( "state" ) + + created, has := oauth2.states[ state ] + if !has { + response.Header( ).Set( "Content-Type", "text/plain" ) + response.WriteHeader( http.StatusForbidden ) + response.Write( []byte( "OAuth2 state not found" ) ) + return + } + + delete ( oauth2.states, state ) + if created < time.Now( ).UTC( ).Unix( ) - 450 { + response.Header( ).Set( "Content-Type", "text/plain" ) + response.WriteHeader( http.StatusForbidden ) + response.Write( []byte( "OAuth2 state is over 7.5 minutes old" ) ) + return + } + + var data []byte + accessToken, e := oauth2.session.AccessToken( code, oauth2.uri.String( ) ) + if e == nil { + data, e = json.MarshalIndent( accessToken, " ", " " ) + } + + if e != nil { + response.Header( ).Set( "Content-Type", "text/plain" ) + response.WriteHeader( http.StatusInternalServerError ) + response.Write( []byte( "Error while getting/processing access token request\n" + e.Error( ) ) ) + return + } + + if oauth2.log { + log.Printf( "Access Token Response:\n%s\n", data ) + } + + body := bytes.NewBuffer( make( []byte, 0, 256 ) ) + body.WriteString( +` + + Access Token Response + +

Access Token

+
`)
+	body.Write( data )
+	body.WriteString(
+`		
+

+ For more information see the + + Discord Developer's Documentation: Authorization Code Grant +

+ +`) + + response.Header( ).Set( "Content-Type", "text/html" ) + response.WriteHeader( http.StatusOK ) + response.Write( body.Bytes( ) ) + +} + +func ( oauth2 handler ) doAuthorizationInfo ( response http.ResponseWriter, request *http.Request ) { + token := request.URL.Query( ).Get( "token" ) + if len( token ) < 1 { + response.Header( ).Set( "Content-Type", "text/plain" ) + response.WriteHeader( http.StatusBadRequest ) + response.Write( []byte( "Token required in the format token='token'\nDo not add the 'Bearer ' to the start" ) ) + return + } + + discord, e := discordgo.New( discordgo.BearerToken( token ) ) + + if e != nil { + response.Header( ).Set( "Content-Type", "text/plain" ) + response.WriteHeader( http.StatusInternalServerError ) + response.Write( []byte( e.Error( ) ) ) + return + } + + var data []byte + authinfo, e := discord.AuthorizationInfo( ) + if e == nil { + data, e = json.MarshalIndent( authinfo, " ", " " ) + } + + if e != nil { + response.Header( ).Set( "Content-Type", "text/plain" ) + response.WriteHeader( http.StatusInternalServerError ) + response.Write( + []byte( + "Unable to get or process token authorization info due to error:\n" + e.Error( ) ) ) + return + } + + if oauth2.log { + log.Printf( "Authorization Info:\n%s\n", data ) + } + + body := bytes.NewBuffer( make( []byte, 0, 256 ) ) + body.WriteString( +` + + Token Authorization Info + +

Authorization Info

+
`)
+	body.Write( data )
+	body.WriteString(
+`		
+

+ For more information see the + + Discord Developer's Documentation: Current Authorization Information +

+ +`) + + response.Header( ).Set( "Content-Type", "text/html" ) + response.WriteHeader( http.StatusOK ) + response.Write( body.Bytes( ) ) +} + +func ( oauth2 handler ) doClientCredentials ( response http.ResponseWriter, _ *http.Request ) { + credentials, e := oauth2.session.ClientCredentials( oauth2.scope ) + if e != nil { + response.Header( ).Set( "Content-Type", "text/plain" ) + response.WriteHeader( http.StatusBadGateway ) + response.Write( + []byte( + "Unable to get client credentials due to error:\n" + e.Error( ) ) ) + return + } + + var data []byte + + if oauth2.log { + if data, e = json.MarshalIndent( credentials, " ", " " ); e != nil { + log.Println( e ) + } else { + log.Printf( "Client Credentials:\n%s\n", data ) + } + } + + credentials.AccessToken = "ABCEFG123REDACTEDTOKEN" + credentials.RefreshToken = "ABCDEFG123REDACTEDTOKEN" + + if data, e = json.MarshalIndent( credentials, "", " " ); e != nil { + response.Header( ).Set( "Content-Type", "text/plain" ) + response.WriteHeader( http.StatusInternalServerError ) + response.Write( []byte( "Error while serializing client credentials\n" + e.Error( ) ) ) + return + } + + body := bytes.NewBuffer( make( []byte, 0, 256 ) ) + body.WriteString( +` + + Client Credentials Grant + +

Client Credentials Grant

+
`)
+	body.Write( data )
+	body.WriteString(
+`		
+

+ Client Credentials Grants are a Discord oauth2 bearer token for the application + developer's discord account and are ment for testing purposes. +

+

+ For more information see the + + Discord Developer's Documentation: Client Credential Grants +

+ +`) + + response.Header( ).Set( "Content-Type", "text/html" ) + response.WriteHeader( http.StatusOK ) + response.Write( body.Bytes( ) ) +} + +func main ( ) { + var secret, redirect, cert, key string + var port int + var server handler + + flag.StringVar( + &server.id, + "id", + "", + "Client id taken from the Settings>OAuth2 section of your application on the Discord Developer's Portal" ) + + flag.StringVar( + &secret, + "secret", + "", + "Client secret taken from the Settings>Oauth2 section of your application on the Discord Developer's Portal" ) + + flag.StringVar( + &redirect, + "redirect", + "", + "Redirect URI set in the Settings>OAuth2 section of your application on the Discord Developer's Portal" ) + + flag.StringVar( + &server.scope, + "scope", + "identify", + "Scope to use for all OAuth2 requests made with the demo server" ) + + flag.IntVar( + &port, + "port", + 0, + "Port number for the demo server to bind to, must be a number between 1 and 65535" ) + + flag.StringVar( + &cert, + "cert", + "", + "Full path to the ssl certificate file if you wish to run in https mode" ) + + flag.StringVar( + &key, + "key", + "", + "Full path to the ssl key file if you wish to run in https mode" ) + + flag.BoolVar( + &server.log, + "log", + false, + "Enable logging responses to stdout" ) + + flag.Parse( ) + + if server.id == "" { + log.Fatalln( + "-client is required and must be a discord application client id" ) + } + + if secret == "" { + log.Fatalln( + "-secret is required and must be a discord client secret" ) + } + + if redirect == "" { + log.Fatalln( + "-redirect is required and must be a redirect uri registered to your application with discord" ) + } + + var e error + server.uri, e = url.Parse( redirect ) + if e != nil { + log.Fatalf( "-redirect must be a valid url\n%s\n", e.Error( ) ) + } + + if !( server.uri.Scheme == "http" || server.uri.Scheme == "https" ) || + len( server.uri.Hostname( ) ) < 5 { + log.Fatalln( "-redirect must be the full url for discord to send user back to and must be the same as registered with discord" ) + } + + if len( server.scope ) < 1 { + log.Fatalln( "-scope is required and must consist of valid discord oauth2 scopes" ) + } + for _, scope := range strings.Fields( server.scope ) { + switch scope { + case + "activites.read", + "activities.write", + "applications.builds.read", + "applications.builds.upload", + "applications.commands", + "applications.commands.update", + "applications.commands.permissions.update", + "applications.entitlements", + "applications.store.update", + "bot", + "connections", + "dm_channels.read", + "email", + "gdm.join", + "guilds", + "guilds.join", + "guilds.members.read", + "identify", + "messages.read", + "relationships.read", + "role_connections.write", + "rpc", + "rpc.activities.write", + "rpc.notifications.read", + "rpc.voice.read", + "rpc.voice.write", + "voice", + "webhook.incoming": + continue + } + log.Fatalf( + "Invalid scope %q see https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes for permitted scopes\n", e.Error( ) ) + } + + var bind string + if port == 0 { + bind = ":" + } else if port > 0 && port < 65536 { + bind = ":" + strconv.Itoa( port ) + } else { + log.Fatal( "-port must be a number between 1 and 65535" ) + } + + ssl := false + if certSet, keySet := len( cert ) > 0, len( key ) > 0; certSet && keySet { + ssl = true + } else if certSet || keySet { + log.Fatalln( "Both -cert and -key must be set for https mode" ) + } + + server.session, e = discordgo.New( discordgo.BasicToken( server.id, secret ) ) + if e != nil { + log.Fatalln( e ) + } + + server.states = make( map[string]int64 ) + + if ssl { + log.Fatalln( http.ListenAndServeTLS( bind, cert, key, server ) ) + } else { + log.Fatalln( http.ListenAndServe( bind, server ) ) + } +} diff --git a/oauth2.go b/oauth2.go index 2fa2d8d54..559237e49 100644 --- a/oauth2.go +++ b/oauth2.go @@ -1,154 +1,158 @@ -// Discordgo - Discord bindings for Go -// Available at https://github.com/bwmarrin/discordgo - -// Copyright 2015-2016 Bruce Marriner . All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// This file contains functions related to Discord OAuth2 endpoints - package discordgo -// ------------------------------------------------------------------------------------------------ -// Code specific to Discord OAuth2 Applications -// ------------------------------------------------------------------------------------------------ +import ( + "fmt" + "net/url" + "time" +) -// The MembershipState represents whether the user is in the team or has been invited into it -type MembershipState int +const ( + ScopeActivitiesRead = "activities.read" + ScopeActivitieswrite = "activities.write" + ScopeApplicationsBuildsRead = "applications.builds.read" + ScopeApplicationsBuildsUpload = "applications.builds.upload" + ScopeApplicationsCommands = "applications.commands" + ScopeApplicationsCommandsUpdate = "applications.commands.update" + ScopeApplicationsCommandsPermissionsUpdate = "applications.commands.permissions.update" + ScopeApplicationsEntitlements = "applications.entitlements" + ScopeApplicationsStoreUpdate = "applications.store.update" + ScopeBot = "bot" + Scopeconnections = "connections" + ScopeDM_ChannelsRead = "dm_channels.read" + ScopeEmail = "email" + ScopeGDM_Join = "gdm.join" + ScopeGuilds = "guilds" + ScopeGuildsJoin = "guilds.join" + ScopeGuildsMembersRead = "guilds.members.read" + ScopeIdentify = "identify" + ScopeMessagesRead = "messages.read" + ScopeRelationshipsRead = "relationships.read" + ScopeRoleConnectionsWrite = "role_connections.write" + ScopeRPC = "rpc" + ScopeRPC_ActivitiesWrite = "rpc.activities.write" + ScopeRPC_NotificationsRead = "rpc.notifications.read" + ScopeRPC_VoiceRead = "rpc.voice.read" + ScopeRPC_VoiceWrite = "rpc.voice.write" + ScopeVoice = "voice" + ScopeWebhookIncoming = "webhook.incoming" +) -// Constants for the different stages of the MembershipState +// Token hint type for use in oauth2 token revocation requests +type TokenHint string const ( - MembershipStateInvited MembershipState = 1 - MembershipStateAccepted MembershipState = 2 + // No token hint given for an oauth2 token revocation request + TokenHintNone TokenHint = "" + // Token given for an oauth2 token revocation request is ( likely ) an access token + TokenHintAccess TokenHint = "access_token" + // Token given for an oauth2 token revocation requsst is ( likely ) a refresh token + TokenHintRefresh TokenHint = "refresh_token" ) -// A TeamMember struct stores values for a single Team Member, extending the normal User data - note that the user field is partial -type TeamMember struct { - User *User `json:"user"` - TeamID string `json:"team_id"` - MembershipState MembershipState `json:"membership_state"` - Permissions []string `json:"permissions"` +// An oauth2 access token response from the Discord Api +type AccessToken struct { + // Oauth2 access token for accessing the Discord Api + AccessToken string `json:"access_token"` + // Oauth2 token type either: Bearer or Bot + TokenType string `json:"token_type"` + // Expire time for the access in seconds from the time the response was sent + ExpiresIn int64 `json:"expires_in"` + + expiresAt int64 + + // Refresh token for requesting a new oauth2 access token ( empty for client credentials ) + RefreshToken string `json:"refresh_token,omitempty"` + // Scope for the token + Scope string `json:"scope"` + // Guild object for advanced bot authorization scoped tokens ( nil for others ) + Guild *Guild `json:"guild,omitempty"` + // Webhook object for webhook tokens ( nil for others ) + Webhook *Webhook `json:"webhook,omitempty"` } -// A Team struct stores the members of a Discord Developer Team as well as some metadata about it -type Team struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Icon string `json:"icon"` - OwnerID string `json:"owner_user_id"` - Members []*TeamMember `json:"members"` +// Creates a new Discord Api session using the token from an access token response +func ( at *AccessToken ) NewSession ( ) ( st *Session, err error ) { + if at.TokenType != "Bearer" { return nil, ErrNotBearerToken } + return New( at.TokenType + " " + at.AccessToken ) } -// Application returns an Application structure of a specific Application -// appID : The ID of an Application -func (s *Session) Application(appID string) (st *Application, err error) { - - body, err := s.RequestWithBucketID("GET", EndpointOAuth2Application(appID), nil, EndpointOAuth2Application("")) - if err != nil { - return - } - - err = unmarshal(body, &st) - return +// Expires returns a time.Time object representing the true expiration time of an access token and whether it is known ( unknown unless the AccessToken object was created by a response from the Discord Api ) +func ( at *AccessToken ) Expires ( ) ( t time.Time, has bool ) { + if at.expiresAt == 0 { return time.Unix( 0, 0 ), false } + return time.Unix( at.expiresAt, 0 ), true } -// Applications returns all applications for the authenticated user -func (s *Session) Applications() (st []*Application, err error) { - - body, err := s.RequestWithBucketID("GET", EndpointOAuth2Applications, nil, EndpointOAuth2Applications) - if err != nil { - return - } - - err = unmarshal(body, &st) - return +// Oauth2 authorization info +type AuthorizationInfo struct { + // Application object + Application *Application `json:"application"` + // Scopes authorized + Scopes []string `json:"scopes"` + // Expire date/time in the ISO 8601 time format for the token + Expires string `json:"expires"` + // User object + User *User `json:"user"` } -// ApplicationCreate creates a new Application -// name : Name of Application / Bot -// uris : Redirect URIs (Not required) -func (s *Session) ApplicationCreate(ap *Application) (st *Application, err error) { - - data := struct { - Name string `json:"name"` - Description string `json:"description"` - }{ap.Name, ap.Description} - - body, err := s.RequestWithBucketID("POST", EndpointOAuth2Applications, data, EndpointOAuth2Applications) - if err != nil { - return +func ( s *Session ) accessToken ( data url.Values, options ...RequestOption ) ( st *AccessToken, err error ) { + if err = s.checkBasicSession(); err != nil { return } + body, err := s.RequestWithBucketID( "POST", EndpointOAuth2Token, data, EndpointOAuth2Token, options... ) + if err == nil { + err = unmarshal( body, &st ) } - - err = unmarshal(body, &st) + st.expiresAt = time.Now().UTC().Unix() + st.ExpiresIn return } -// ApplicationUpdate updates an existing Application -// var : desc -func (s *Session) ApplicationUpdate(appID string, ap *Application) (st *Application, err error) { - - data := struct { - Name string `json:"name"` - Description string `json:"description"` - }{ap.Name, ap.Description} - - body, err := s.RequestWithBucketID("PUT", EndpointOAuth2Application(appID), data, EndpointOAuth2Application("")) - if err != nil { - return +// AccessToken performs an oauth2 token request for the code and returns the access token response ( requires a session created with a 'Basic' token using the applications client id and client secret ) +// code : code obtained from Discord oauth2 redirect +// redirectUri: redirect uri used in the Discord oauth2 redirect +func ( s *Session ) AccessToken ( code string, redirectUri string, options ...RequestOption ) ( st *AccessToken, err error ) { + data := url.Values{ + "grant_type": []string{ "authorization_code" }, + "code": []string{ code }, + "redirect_uri": []string{ redirectUri }, } - - err = unmarshal(body, &st) - return + return s.accessToken( data, options... ) } -// ApplicationDelete deletes an existing Application -// appID : The ID of an Application -func (s *Session) ApplicationDelete(appID string) (err error) { - - _, err = s.RequestWithBucketID("DELETE", EndpointOAuth2Application(appID), nil, EndpointOAuth2Application("")) - if err != nil { - return +// AccessTokenRefresh performs an oauth2 token request using the refresh token of a previous access token response ( requires a session created with a 'Basic' token using the applications client id and client secret; and not one created from the 'Bearer'/'Bot' token of the user ) +// refreshToken: refresh token obtained from a prior oauth2 token response +func ( s *Session ) AccessTokenRefresh ( refreshToken string, options ...RequestOption ) ( st *AccessToken, err error ) { + data := url.Values{ + "grant_type": []string{ "refresh_token" }, + "refresh_token": []string{ refreshToken }, } - - return + return s.accessToken( data, options... ) } -// Asset struct stores values for an asset of an application -type Asset struct { - Type int `json:"type"` - ID string `json:"id"` - Name string `json:"name"` +// ClientCredentials performs a client credentials request using scopes retreiving developers oauth2 token for testing purposes. ( requires a session created with a 'Basic' token using the applications client id and client secret ) +// scope: the scope to request the token be issued for +func ( s *Session ) ClientCredentials ( scope string, options ...RequestOption ) ( st *AccessToken, err error ) { + data := url.Values{ + "grant_type": []string{ "client_credentials" }, + "scope": []string{ scope }, + } + return s.accessToken( data, options... ) } -// ApplicationAssets returns an application's assets -func (s *Session) ApplicationAssets(appID string) (ass []*Asset, err error) { - - body, err := s.RequestWithBucketID("GET", EndpointOAuth2ApplicationAssets(appID), nil, EndpointOAuth2ApplicationAssets("")) - if err != nil { - return +// AccessTokenRevoke preforms a oauth2 token revocation request for token ( requires a session created with a 'Basic' token using the applications client id and client secret; and not one created from the 'Bearer'/'Bot' token ) +// token : the access token or that access token's refresh token being revoked +// tokenType: the hint for which type of token has been provided to search for +func ( s *Session ) AccessTokenRevoke ( token string, tokenType TokenHint, options ...RequestOption ) ( st []byte, err error ) { + if err = s.checkBasicSession( ); err != nil { return } + data := url.Values{ "token": []string{ token } } + if tokenType != TokenHintNone { + data.Set( "token_type_hint", string( tokenType ) ) } - - err = unmarshal(body, &ass) + st, err = s.RequestWithBucketID( "POST", EndpointOAuth2TokenRevoke, data, EndpointOAuth2TokenRevoke, options... ) return } -// ------------------------------------------------------------------------------------------------ -// Code specific to Discord OAuth2 Application Bots -// ------------------------------------------------------------------------------------------------ - -// ApplicationBotCreate creates an Application Bot Account -// -// appID : The ID of an Application -// -// NOTE: func name may change, if I can think up something better. -func (s *Session) ApplicationBotCreate(appID string) (st *User, err error) { - - body, err := s.RequestWithBucketID("POST", EndpointOAuth2ApplicationsBot(appID), nil, EndpointOAuth2ApplicationsBot("")) - if err != nil { - return - } - - err = unmarshal(body, &st) +// AuthorizationInfo retreives the authorization information of the access token ( requires a session created with the 'Bearer' token received from a previous oauth2 access token response; and not a 'Basic' token created with the applications client id and client secret ) +func ( s *Session ) AuthorizationInfo ( options ...RequestOption ) ( st *AuthorizationInfo, err error ) { + if err = s.checkBearerSession( ); err != nil { return } + fmt.Println(s.Identify.Token) + body, err := s.RequestWithBucketID( "GET", EndpointOAuth2AuthorizationInfo, nil, EndpointOAuth2AuthorizationInfo, options... ) + if err == nil { err = unmarshal( body, &st ) } return } diff --git a/restapi.go b/restapi.go index 97d1b588a..18c46223a 100644 --- a/restapi.go +++ b/restapi.go @@ -32,6 +32,9 @@ import ( // All error constants var ( + ErrNotBasicToken = errors.New( "token must be Basic token for oauth2 requests" ) + ErrNotBearerToken = errors.New( "token must be Bearer token for this request" ) + ErrNotBotToken = errors.New( "token must be Bot token for bot logins" ) ErrJSONUnmarshal = errors.New("json unmarshal") ErrStatusOffline = errors.New("You can't set your Status to offline") ErrVerificationLevelBounds = errors.New("VerificationLevel out of bounds, should be between 0 and 3") @@ -170,15 +173,21 @@ func (s *Session) Request(method, urlStr string, data interface{}, options ...Re // RequestWithBucketID makes a (GET/POST/...) Requests to Discord REST API with JSON data. func (s *Session) RequestWithBucketID(method, urlStr string, data interface{}, bucketID string, options ...RequestOption) (response []byte, err error) { - var body []byte - if data != nil { - body, err = Marshal(data) - if err != nil { - return - } + var (body []byte; contentType string) + if data == nil { + return s.request(method, urlStr, contentType, body, bucketID, 0, options...) } - return s.request(method, urlStr, "application/json", body, bucketID, 0, options...) + if values, is := data.(url.Values); is { + body = []byte(values.Encode()) + contentType = "application/x-www-form-urlencoded" + } else if body, err = Marshal(data); err != nil { + return []byte{}, err + } else if len( body ) > 0 { + contentType = "application/json" + } + + return s.request(method, urlStr, contentType, body, bucketID, 0, options...) } // request makes a (GET/POST/...) Requests to Discord REST API. @@ -261,7 +270,6 @@ func (s *Session) RequestWithLockedBucket(method, urlStr, contentType string, b } log.Printf("API RESPONSE BODY :: [%s]\n\n\n", response) } - switch resp.StatusCode { case http.StatusOK: case http.StatusCreated: @@ -634,7 +642,7 @@ func (s *Session) GuildCreate(name string, options ...RequestOption) (st *Guild, // GuildEdit edits a new Guild // guildID : The ID of a Guild -// g : A GuildParams struct with the values Name, Region and VerificationLevel defined. +// g : A GuildParams struct with the values Name, Region and VerificationLevel defined. func (s *Session) GuildEdit(guildID string, g *GuildParams, options ...RequestOption) (st *Guild, err error) { // Bounds checking for VerificationLevel, interval: [0, 4] @@ -1121,7 +1129,7 @@ func (s *Session) GuildRoleCreate(guildID string, data *RoleParams, options ...R // GuildRoleEdit updates an existing Guild Role and returns updated Role data. // guildID : The ID of a Guild. // roleID : The ID of a Role. -// data : Updated Role data. +// data : Updated Role data. func (s *Session) GuildRoleEdit(guildID, roleID string, data *RoleParams, options ...RequestOption) (st *Role, err error) { // Prevent sending a color int that is too big. @@ -1166,8 +1174,8 @@ func (s *Session) GuildRoleDelete(guildID, roleID string, options ...RequestOpti // GuildPruneCount Returns the number of members that would be removed in a prune operation. // Requires 'KICK_MEMBER' permission. -// guildID : The ID of a Guild. -// days : The number of days to count prune for (1 or more). +// guildID : The ID of a Guild. +// days : The number of days to count prune for (1 or more). func (s *Session) GuildPruneCount(guildID string, days uint32, options ...RequestOption) (count uint32, err error) { count = 0 @@ -1198,8 +1206,8 @@ func (s *Session) GuildPruneCount(guildID string, days uint32, options ...Reques // GuildPrune Begin as prune operation. Requires the 'KICK_MEMBERS' permission. // Returns an object with one 'pruned' key indicating the number of members that were removed in the prune operation. -// guildID : The ID of a Guild. -// days : The number of days to count prune for (1 or more). +// guildID : The ID of a Guild. +// days : The number of days to count prune for (1 or more). func (s *Session) GuildPrune(guildID string, days uint32, options ...RequestOption) (count uint32, err error) { count = 0 @@ -1265,9 +1273,9 @@ func (s *Session) GuildIntegrationCreate(guildID, integrationType, integrationID // guildID : The ID of a Guild. // integrationType : The Integration type. // integrationID : The ID of an integration. -// expireBehavior : The behavior when an integration subscription lapses (see the integration object documentation). +// expireBehavior : The behavior when an integration subscription lapses (see the integration object documentation). // expireGracePeriod : Period (in seconds) where the integration will ignore lapsed subscriptions. -// enableEmoticons : Whether emoticons should be synced for this integration (twitch only currently). +// enableEmoticons : Whether emoticons should be synced for this integration (twitch only currently). func (s *Session) GuildIntegrationEdit(guildID, integrationID string, expireBehavior, expireGracePeriod int, enableEmoticons bool, options ...RequestOption) (err error) { data := struct { @@ -2385,7 +2393,7 @@ func (s *Session) WebhookExecute(webhookID, token string, wait bool, data *Webho // webhookID: The ID of a webhook. // token : The auth token for the webhook // wait : Waits for server confirmation of message send and ensures that the return struct is populated (it is nil otherwise) -// threadID : Sends a message to the specified thread within a webhook's channel. The thread will automatically be unarchived. +// threadID : Sends a message to the specified thread within a webhook's channel. The thread will automatically be unarchived. func (s *Session) WebhookThreadExecute(webhookID, token string, wait bool, threadID string, data *WebhookParams, options ...RequestOption) (st *Message, err error) { return s.webhookExecute(webhookID, token, wait, threadID, data, options...) } @@ -2465,7 +2473,7 @@ func (s *Session) MessageReactionAdd(channelID, messageID, emojiID string, optio // channelID : The channel ID. // messageID : The message ID. // emojiID : Either the unicode emoji for the reaction, or a guild emoji identifier. -// userID : @me or ID of the user to delete the reaction for. +// userID : @me or ID of the user to delete the reaction for. func (s *Session) MessageReactionRemove(channelID, messageID, emojiID, userID string, options ...RequestOption) error { // emoji such as #⃣ need to have # escaped diff --git a/structs.go b/structs.go index 8499bead7..6e0088f63 100644 --- a/structs.go +++ b/structs.go @@ -136,6 +136,22 @@ type Session struct { wsMutex sync.Mutex } +func ( s *Session ) checkBasicSession( ) ( err error ) { + if s.Identify.Token[:6] != "Basic " { return ErrNotBasicToken } + return +} + + +func ( s *Session ) checkBearerSession( ) ( err error ) { + if s.Identify.Token[:7] != "Bearer " { return ErrNotBearerToken } + return +} + +func ( s *Session ) checkBotSession( ) ( err error ) { + if s.Identify.Token[:4] != "Bot " { return ErrNotBotToken } + return +} + // Application stores values for a Discord Application type Application struct { ID string `json:"id,omitempty"` @@ -1286,6 +1302,9 @@ type UserGuild struct { ApproximatePresenceCount int `json:"approximate_presence_count"` } +func (g *UserGuild) IconURL(size string) string { + return iconURL(g.Icon, EndpointGuildIcon(g.ID, g.Icon), EndpointGuildIconAnimated(g.ID, g.Icon), size) +} // GuildFeature indicates the presence of a feature in a guild type GuildFeature string @@ -2247,7 +2266,7 @@ type Identify struct { Shard *[2]int `json:"shard,omitempty"` Presence GatewayStatusUpdate `json:"presence,omitempty"` Intents Intent `json:"intents"` -} +} // IdentifyProperties contains the "properties" portion of an Identify packet // https://discord.com/developers/docs/topics/gateway#identify-identify-connection-properties diff --git a/util.go b/util.go index 957f30187..56309509f 100644 --- a/util.go +++ b/util.go @@ -6,6 +6,7 @@ import ( "io" "mime/multipart" "net/textproto" + "encoding/base64" "strconv" "strings" "time" @@ -76,6 +77,18 @@ func MultipartBodyWithJSON(data interface{}, files []*File) (requestContentType return bodywriter.FormDataContentType(), body.Bytes(), nil } +func BasicToken( clientId string, clientSecret string ) ( token string ) { + return "Basic " + base64.StdEncoding.EncodeToString([]byte( clientId + ":" + clientSecret )) +} + +func BearerToken( token string ) ( bearerToken string ) { + return "Bearer " + token +} + +func BotToken( token string ) ( botToken string ) { + return "Bot " + token +} + func avatarURL(avatarHash, defaultAvatarURL, staticAvatarURL, animatedAvatarURL, size string) string { var URL string if avatarHash == "" {