diff options
| author | Zeyla Hellyer <[email protected]> | 2017-12-03 13:48:37 -0800 |
|---|---|---|
| committer | Zeyla Hellyer <[email protected]> | 2017-12-03 13:48:37 -0800 |
| commit | bc1870baa0ae1cf0f133c3f7009b11dab2c4f7b9 (patch) | |
| tree | 2fdbdef751b2c67be6c7f6181a78d118b716cc43 | |
| download | oauth-bc1870baa0ae1cf0f133c3f7009b11dab2c4f7b9.tar.xz oauth-bc1870baa0ae1cf0f133c3f7009b11dab2c4f7b9.zip | |
| -rw-r--r-- | .editorconfig | 18 | ||||
| -rw-r--r-- | .gitignore | 5 | ||||
| -rw-r--r-- | .travis.yml | 16 | ||||
| -rw-r--r-- | Cargo.toml | 26 | ||||
| -rw-r--r-- | LICENSE.md | 15 | ||||
| -rw-r--r-- | README.md | 43 | ||||
| -rw-r--r-- | examples/rocket.rs | 94 | ||||
| -rw-r--r-- | src/bridge/hyper.rs | 145 | ||||
| -rw-r--r-- | src/bridge/mod.rs | 6 | ||||
| -rw-r--r-- | src/constants.rs | 9 | ||||
| -rw-r--r-- | src/error.rs | 54 | ||||
| -rw-r--r-- | src/lib.rs | 45 | ||||
| -rw-r--r-- | src/model.rs | 196 | ||||
| -rw-r--r-- | src/scope.rs | 77 | ||||
| -rw-r--r-- | src/utils.rs | 130 |
15 files changed, 879 insertions, 0 deletions
diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..edd529e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.rs] +indent_size = 4 + +[*.md] +indent_size = 4 +trim_trailing_whitespace = false + +[*.yml] +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d59474 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# editor +.idea/ +.vscode/ + +target/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..99112c2 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,16 @@ +language: rust +rust: + - stable + - beta + - nightly + +sudo: false +os: + - linux + - osx + +cache: + cargo: true + +script: + - cargo test diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7bd2ebf --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +authors = ["Zeyla Hellyer <[email protected]>"] +description = "Serenity ecosystem oauth support for the Discord API." +documentation = "https://docs.rs/serenity-oauth" +homepage = "https://github.com/serenity-rs/oauth" +keywords = ["discord", "oauth", "serenity"] +license = "ISC" +name = "serenity-oauth" +readme = "README.md" +repository = "https://github.com/serenity-rs/oauth.git" +version = "0.1.0" + +[dependencies] +hyper = "~0.10" +percent-encoding = "^1.0" +serde = "^1.0" +serde_derive = "^1.0" +serde_json = "^1.0" +serde_urlencoded = "~0.5" +serenity-model = { git = "https://github.com/serenity-rs/model" } + +[dev-dependencies] +hyper = "~0.10" +hyper-native-tls = "~0.2" +rocket = "~0.3" +rocket_codegen = "~0.3" diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..563ba1f --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,15 @@ +ISC License (ISC) + +Copyright (c) 2017, Zeyla Hellyer <[email protected]> + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c051429 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# serenity-oauth + +`serenity-oauth` is a collection of HTTP library support bridges for +interacting with the OAuth2 API that Discord uses. + +It includes support for sending code exchange requests and refresh token +requests. + +Included are models in the `model` directory that represent request bodies +and response bodies. The `Scope` enum represents possible OAuth2 scopes +that can be granted. + +In the `utils` module, functions to produce authorization URLs are +available. For example, `utils::bot_authorization_url` can be used to +produce a URL that can be used to redirect users to authorize an application +with the `Scope::Bot` scope. + +### Installation + +Add the following to your `Cargo.toml`: + +```toml +[dependencies] +serenity-oauth = { git = "https://github.com/serenity-rs/oauth" } +``` + +And then the following to your `main.rs` or `lib.rs`: + +```rust +extern crate serenity_oauth; +``` + +### Examples + +For an example of how to use this in a real-world program, see the [`examples`] +directory. + +### License + +This project is licensed under [ISC][license]. + +[license]: https://github.com/serenity-rs/oauth/blob/master/LICENSE.md +[`examples`]: https://github.com/serenity-rs/oauth/tree/master/examples diff --git a/examples/rocket.rs b/examples/rocket.rs new file mode 100644 index 0000000..d661e2f --- /dev/null +++ b/examples/rocket.rs @@ -0,0 +1,94 @@ +//! This is a sample program for running a Rocket.rs server. This redirects +//! users to Discord's authorization page, requesting the `identity` scope. +//! +//! Once they have authorized, it will take the `code` given and then exchange +//! it for an access token, which can be used to access the user's identity. +//! +//! This example requires the following environment variables, both available +//! from your Discord application's settings: +//! +//! - `DISCORD_CLIENT_ID` +//! - `DISCORD_CLIENT_SECRET` +//! +//! You will also need to register a redirect URI. Running this locally would +//! cause the redirect URI to be `http://localhost:8000` for example. This can +//! be registered in your application's settings. +//! +//! Example of how to run this: +//! +//! `$ git clone https://github.com/serenity-rs/oauth` +//! `$ cd oauth` +//! `$ DISCORD_CLIENT_SECRET=my_secret DISCORD_CLIENT_ID=my_client_id cargo run --example rocket` + +#![feature(custom_derive, plugin)] +#![plugin(rocket_codegen)] + +extern crate hyper; +extern crate hyper_native_tls; +extern crate serenity_oauth; +extern crate rocket; + +use hyper::net::HttpsConnector; +use hyper::Client as HyperClient; +use hyper_native_tls::NativeTlsClient; +use rocket::response::Redirect; +use serenity_oauth::model::AccessTokenExchangeRequest; +use serenity_oauth::{DiscordOAuthHyperRequester, Scope}; +use std::env; +use std::error::Error; + +#[derive(Debug, FromForm)] +struct Params { + code: String, +} + +fn get_client_id() -> u64 { + env::var("DISCORD_CLIENT_ID") + .expect("No DISCORD_CLIENT_ID present") + .parse::<u64>() + .expect("Error parsing DISCORD_CLIENT_ID into u64") +} + +fn get_client_secret() -> String { + env::var("DISCORD_CLIENT_SECRET") + .expect("No DISCORD_CLIENT_SECRET present") +} + +#[get("/callback?<params>")] +fn get_callback(params: Params) -> Result<String, Box<Error>> { + // Exchange the code for an access token. + let ssl = NativeTlsClient::new()?; + let connector = HttpsConnector::new(ssl); + let client = HyperClient::with_connector(connector); + + let response = client.exchange_code(&AccessTokenExchangeRequest::new( + get_client_id(), + get_client_secret(), + params.code, + "http://localhost:8000/callback", + ))?; + + Ok(format!("The user's access token is: {}", response.access_token)) +} + +#[get("/")] +fn get_redirect() -> Redirect { + // Although this example does not use a state, you _should always_ use one + // in production for security purposes. + let url = serenity_oauth::utils::authorization_code_grant_url( + get_client_id(), + &[Scope::Identify], + None, + "http://localhost:8000/callback", + ); + + Redirect::to(&url) +} + +fn main() { + rocket::ignite() + .mount("/", routes![ + get_callback, + get_redirect, + ]).launch(); +} diff --git a/src/bridge/hyper.rs b/src/bridge/hyper.rs new file mode 100644 index 0000000..4a7eba2 --- /dev/null +++ b/src/bridge/hyper.rs @@ -0,0 +1,145 @@ +//! Bridged support for the `hyper` HTTP client. + +use hyper::client::{Body, Client as HyperClient}; +use hyper::header::ContentType; +use serde_json; +use serde_urlencoded; +use ::constants::BASE_TOKEN_URI; +use ::model::{ + AccessTokenExchangeRequest, + AccessTokenResponse, + RefreshTokenRequest, +}; +use ::Result; + +/// A trait used that implements methods for interacting with Discord's OAuth2 +/// API on Hyper's client. +/// +/// # Examples +/// +/// Bringing in the trait and creating a client. Since the trait is in scope, +/// the instance of hyper's Client will have those methods available: +/// +/// ```rust,no_run +/// extern crate hyper; +/// extern crate serenity_oauth; +/// +/// # fn main() { +/// use hyper::Client; +/// +/// let client = Client::new(); +/// +/// // At this point, the methods defined by the trait are not in scope. By +/// // using the trait, they will be. +/// use serenity_oauth::DiscordOAuthHyperRequester; +/// +/// // The methods defined by `DiscordOAuthHyperRequester` are now in scope and +/// // implemented on the instance of hyper's `Client`. +/// # } +/// ``` +/// +/// For examples of how to use the trait with the Client, refer to the trait's +/// methods. +pub trait DiscordOAuthHyperRequester { + /// Exchanges a code for the user's access token. + /// + /// # Examples + /// + /// Exchange a code for an access token: + /// + /// ```rust,no_run + /// extern crate hyper; + /// extern crate serenity_oauth; + /// + /// # use std::error::Error; + /// # + /// # fn try_main() -> Result<(), Box<Error>> { + /// use hyper::Client; + /// use serenity_oauth::model::AccessTokenExchangeRequest; + /// use serenity_oauth::DiscordOAuthHyperRequester; + /// + /// let request_data = AccessTokenExchangeRequest::new( + /// 249608697955745802, + /// "dd99opUAgs7SQEtk2kdRrTMU5zagR2a4", + /// "user code here", + /// "https://myapplication.website", + /// ); + /// + /// let client = Client::new(); + /// let response = client.exchange_code(&request_data)?; + /// + /// println!("Access token: {}", response.access_token); + /// # Ok(()) + /// # } + /// # + /// # fn main() { + /// # try_main().unwrap(); + /// # } + /// ``` + fn exchange_code(&self, request: &AccessTokenExchangeRequest) + -> Result<AccessTokenResponse>; + + /// Exchanges a refresh token, returning a new refresh token and fresh + /// access token. + /// + /// # Examples + /// + /// Exchange a refresh token: + /// + /// ```rust,no_run + /// extern crate hyper; + /// extern crate serenity_oauth; + /// + /// # use std::error::Error; + /// # + /// # fn try_main() -> Result<(), Box<Error>> { + /// use hyper::Client; + /// use serenity_oauth::model::RefreshTokenRequest; + /// use serenity_oauth::DiscordOAuthHyperRequester; + /// + /// let request_data = RefreshTokenRequest::new( + /// 249608697955745802, + /// "dd99opUAgs7SQEtk2kdRrTMU5zagR2a4", + /// "user code here", + /// "https://myapplication.website", + /// ); + /// + /// let client = Client::new(); + /// let response = client.exchange_refresh_token(&request_data)?; + /// + /// println!("Fresh access token: {}", response.access_token); + /// # Ok(()) + /// # } + /// # + /// # fn main() { + /// # try_main().unwrap(); + /// # } + /// ``` + fn exchange_refresh_token(&self, request: &RefreshTokenRequest) + -> Result<AccessTokenResponse>; +} + +impl DiscordOAuthHyperRequester for HyperClient { + fn exchange_code(&self, request: &AccessTokenExchangeRequest) + -> Result<AccessTokenResponse> { + let body = serde_urlencoded::to_string(request)?; + + let response = self.post(BASE_TOKEN_URI) + .header(ContentType::form_url_encoded()) + .body(Body::BufBody(body.as_bytes(), body.len())) + .send()?; + + serde_json::from_reader(response).map_err(From::from) + } + + fn exchange_refresh_token(&self, request: &RefreshTokenRequest) + -> Result<AccessTokenResponse> { + let body = serde_json::to_string(request)?; + + let response = self.post(BASE_TOKEN_URI) + .body(Body::BufBody(body.as_bytes(), body.len())) + .send()?; + + serde_json::from_reader(response).map_err(From::from) + } +} diff --git a/src/bridge/mod.rs b/src/bridge/mod.rs new file mode 100644 index 0000000..92be3da --- /dev/null +++ b/src/bridge/mod.rs @@ -0,0 +1,6 @@ +//! A module containing bridges to HTTP clients. +//! +//! This contains traits implemented on HTTP clients, as well as oneshot +//! functions that create one-off clients for ease of use. + +pub mod hyper; diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..76e4e0c --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,9 @@ +//! A set of constants around the OAuth2 API. + +/// The base authorization URI, used for authorizing an application. +pub const BASE_AUTHORIZE_URI: &str = "https://discordapp.com/api/oauth2/authorize"; +/// The revocation URL, used to revoke an access token. +pub const BASE_REVOKE_URI: &str = "https://discordapp.com/api/oauth2/revoke"; +/// The token URI, used for exchanging a refresh token for a fresh access token +/// and new refresh token. +pub const BASE_TOKEN_URI: &str = "https://discordapp.com/api/oauth2/token"; diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..3e6a66e --- /dev/null +++ b/src/error.rs @@ -0,0 +1,54 @@ +use hyper::Error as HyperError; +use serde_json::Error as JsonError; +use serde_urlencoded::ser::Error as UrlEncodeError; +use std::error::Error as StdError; +use std::fmt::{Display, Formatter, Result as FmtResult}; +use std::result::Result as StdResult; + +/// Result type used throughout the library's public result functions. +pub type Result<T> = StdResult<T, Error>; + +/// Standard error enum used to wrap different potential error types. +#[derive(Debug)] +pub enum Error { + /// An error from the `hyper` crate. + Hyper(HyperError), + /// An error from the `serde_json` crate. + Json(JsonError), + /// An error from the `serde_urlencoded` crate. + UrlEncode(UrlEncodeError), +} + +impl From<HyperError> for Error { + fn from(err: HyperError) -> Self { + Error::Hyper(err) + } +} + +impl From<JsonError> for Error { + fn from(err: JsonError) -> Self { + Error::Json(err) + } +} + +impl From<UrlEncodeError> for Error { + fn from(err: UrlEncodeError) -> Self { + Error::UrlEncode(err) + } +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + f.write_str(self.description()) + } +} + +impl StdError for Error { + fn description(&self) -> &str { + match *self { + Error::Hyper(ref inner) => inner.description(), + Error::Json(ref inner) => inner.description(), + Error::UrlEncode(ref inner) => inner.description(), + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9fd0556 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,45 @@ +//! # serenity-oauth +//! +//! `serenity-oauth` is a collection of HTTP library support bridges for +//! interacting with the OAuth2 API that Discord uses. +//! +//! It includes support for sending code exchange requests and refresh token +//! requests. +//! +//! Included are models in the [`model`] directory that represent request bodies +//! and response bodies. The [`Scope`] enum represents possible OAuth2 scopes +//! that can be granted. +//! +//! In the [`utils`] module, functions to produce authorization URLs are +//! available. For example, [`utils::bot_authorization_url`] can be used to +//! produce a URL that can be used to redirect users to authorize an application +//! with the [`Scope::Bot`] scope. +//! +//! [`Scope`]: enum.Scope.html +//! [`Scope::Bot`]: enum.Scope.html#variant.Bot +//! [`model`]: model/ +//! [`utils`]: utils/ +//! [`utils::bot_authorization_url`]: utils/fn.bot_authorization_url.html + +#![deny(missing_docs)] + +#[macro_use] extern crate serde_derive; + +extern crate hyper; +extern crate percent_encoding; +extern crate serde; +extern crate serde_json; +extern crate serde_urlencoded; +extern crate serenity_model; + +pub mod bridge; +pub mod constants; +pub mod model; +pub mod utils; + +mod error; +mod scope; + +pub use bridge::hyper::DiscordOAuthHyperRequester; +pub use error::{Error, Result}; +pub use scope::Scope; diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..86e3005 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,196 @@ +//! A collection of models that can be deserialized from response bodies and +//! serialized into request bodies. + +use serenity_model::{PartialGuild, Webhook}; + +/// Structure of data used as the body of a request to exchange the [`code`] for +/// an access token. +/// +/// [`code`]: #structfield.code +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AccessTokenExchangeRequest { + /// Your application's client ID. + pub client_id: u64, + /// Your application's client secret. + pub client_secret: String, + /// The code in the query parameters to your redirect URI. + pub code: String, + /// The type of grant. + /// + /// Must be set to `authorization_code`. + /// + /// If using [`AccessTokenExchangeRequest::new`], this will automatically be + /// set for you. + pub grant_type: String, + /// Your redirect URI. + pub redirect_uri: String, +} + +impl AccessTokenExchangeRequest { + /// Creates a new request body for exchanging a code for an access token. + /// + /// # Examples + /// + /// Create a new request and assert that the grant type is correct: + /// + /// ```rust + /// use serenity_oauth::model::AccessTokenExchangeRequest; + /// + /// let request = AccessTokenExchangeRequest::new( + /// 249608697955745802, + /// "dd99opUAgs7SQEtk2kdRrTMU5zagR2a4", + /// "user code here", + /// "https://myapplication.website", + /// ); + /// + /// assert_eq!(request.grant_type, "authorization_code"); + /// ``` + pub fn new<S, T, U>( + client_id: u64, + client_secret: S, + code: T, + redirect_uri: U, + ) -> Self where S: Into<String>, T: Into<String>, U: Into<String> { + Self { + client_secret: client_secret.into(), + code: code.into(), + grant_type: "authorization_code".to_owned(), + redirect_uri: redirect_uri.into(), + client_id, + } + } +} + +/// Response data containing a new access token and refresh token. +/// +/// This can be received when either: +/// +/// 1. exchanging a code for an access token; +/// 2. exchanging a refresh token for a fresh access token. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AccessTokenResponse { + /// The user's access token. + pub access_token: String, + /// The number of seconds until the access token expires. + pub expires_in: u64, + /// The refresh token to use when the access token expires. + pub refresh_token: String, + /// The scope that is granted. + pub scope: String, + /// The type of token received. + pub token_type: String, +} + +/// Response data containing an access token, but without a refresh token. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ClientCredentialsAccessTokenResponse { + /// The user's access token. + pub access_token: String, + /// The number of seconds until the access token expires. + pub expires_in: u64, + /// The scope that is granted. + pub scope: String, + /// The type of token received. + pub token_type: String, +} + +/// An extended [`Scope::Bot`] authorization flow. +/// +/// This will authorize the application as a bot into a user's selected guild, +/// as well as granting additional scopes. +/// +/// [`Scope::Bot`]: ../enum.Scope.html#variant.Bot +#[derive(Clone, Debug, Deserialize)] +pub struct ExtendedBotAuthorizationResponse { + /// The user's access token. + pub access_token: String, + /// The number of seconds until the access token expires. + pub expires_in: u64, + /// Partial guild data that the application was authorized into. + pub guild: PartialGuild, + /// The refresh token to use when the access token expires. + pub refresh_token: String, + /// The scope that is granted. + pub scope: String, + /// The type of token received. + pub token_type: String, +} + +/// Request for exchanging a refresh token for a new access token. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct RefreshTokenRequest { + /// Your application's client ID. + pub client_id: u64, + /// Your application's client secret. + pub client_secret: String, + /// The type of grant. + /// + /// Must be set to `refresh_token`. + /// + /// If using [`RefreshTokenRequest::new`], this will automatically be + /// set for you. + pub grant_type: String, + /// Your redirect URI. + pub redirect_uri: String, + /// The user's refresh token. + pub refresh_token: String, +} + +impl RefreshTokenRequest { + /// Creates a new request body for refreshing an access token using a + /// refresh token. + /// + /// # Examples + /// + /// Create a new request and assert that the grant type is correct: + /// + /// ```rust + /// use serenity_oauth::model::RefreshTokenRequest; + /// + /// let request = RefreshTokenRequest::new( + /// 249608697955745802, + /// "dd99opUAgs7SQEtk2kdRrTMU5zagR2a4", + /// "user code here", + /// "https://myapplication.website", + /// ); + /// + /// assert_eq!(request.grant_type, "refresh_token"); + /// ``` + pub fn new<S, T, U>( + client_id: u64, + client_secret: S, + redirect_uri: T, + refresh_token: U, + ) -> Self where S: Into<String>, T: Into<String>, U: Into<String> { + Self { + client_secret: client_secret.into(), + grant_type: "refresh_token".to_owned(), + redirect_uri: redirect_uri.into(), + refresh_token: refresh_token.into(), + client_id, + } + } +} + +/// The response data from a successful trading of a code for an access token +/// after authorization of [`Scope::WebhookIncoming`]. +/// +/// You should store [`webhook`]'s `id` and `token` structfields. +/// +/// [`Scope::WebhookIncoming`]: ../enum.Scope.html#variant.WebhookIncoming +/// [`webhook`]: #structfield.webhook +#[derive(Clone, Debug, Deserialize)] +pub struct WebhookTokenResponse { + /// The user's access token. + pub access_token: String, + /// The number of seconds until the access token expires. + pub expires_in: u64, + /// The refresh token to use when the access token expires. + pub refresh_token: String, + /// The scope that is granted. + pub scope: String, + /// The type of token received. + pub token_type: String, + /// Information about the webhook created. + pub webhook: Webhook, +} diff --git a/src/scope.rs b/src/scope.rs new file mode 100644 index 0000000..8ff55fc --- /dev/null +++ b/src/scope.rs @@ -0,0 +1,77 @@ +use std::fmt::{Display, Formatter, Result as FmtResult}; + +/// A Discord OAuth2 scope that can be granted. +/// +/// If you require a scope that is not registered here, use [`Scope::Other`] and +/// notify the library developers about the missing scope. +/// +/// **Note**: The [`Scope::Bot`] and [`Scope::GuildsJoin`] scopes require you to +/// have a bot account linked to your application. Also, in order to add a user +/// to a guild, your bot has to already belong in that guild. +/// +/// [`Scope::Bot`]: #variant.Bot +/// [`Scope::GuildsJoin`]: #variant.GuildsJoin +/// [`Scope::Other`]: #variant.Other +#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] +pub enum Scope { + /// For OAuth2 bots, this puts the bot in the user's selected guild by + /// default. + Bot, + /// Allows the `/users/@me/connections` API endpoint to return linked + /// third-party accounts. + Connections, + /// Enables the `/users/@me` API endpoint to return an `email` field. + Email, + /// Allows the `/users/@me` API endpoint, without the `email` field. + Identify, + /// Allows the `/users/@me/guilds` API endpoint to return basic information + /// about all of a user's guilds. + Guilds, + /// Allows the `/invites/{code}` API endpoint to be used for joining users + /// to a guild. + GuildsJoin, + /// Allows your application to join users to a group DM. + GdmJoin, + /// For local RPC server API access, this allows you to read messages from + /// all cliuent channels. + /// + /// This is otherwise restricted to channels/guilds your application + /// creates. + MessagesRead, + /// For local RPC server access, this allows you to control a user's local + /// Discord client. + Rpc, + /// For local RPC server API access, this allows you to access the API as + /// the local user. + RpcApi, + /// For local RPC server API access, this allows you to receive + /// notifications pushed out to the user. + RpcNotificationsRead, + /// This generates a webhook that is returned in the OAuth token response + /// for authorization code grants. + WebhookIncoming, + /// A scope that does not have a matching enum variant. + Other(String), +} + +impl Display for Scope { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + use self::Scope::*; + + f.write_str(match *self { + Bot => "bot", + Connections => "connections", + Email => "email", + Identify => "identify", + Guilds => "guilds", + GuildsJoin => "guilds.join", + GdmJoin => "gdm.join", + MessagesRead => "messages.read", + Rpc => "rpc", + RpcApi => "rpc.api", + RpcNotificationsRead => "rpc.notifications.read", + WebhookIncoming => "webhook.incoming", + Other(ref inner) => inner, + }) + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..e11a313 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,130 @@ +//! A collection of functions for use with the OAuth2 API. +//! +//! This includes functions for easily generating URLs to redirect users to for +//! authorization. + +pub use serenity_model::Permissions; + +use constants::BASE_AUTHORIZE_URI; +use percent_encoding; +use super::Scope; +use std::fmt::Write; + +/// Creates a URL for a simple bot authorization flow. +/// +/// This is a special, +/// server-less and callback-less OAuth2 flow that makes it +/// easy for users to add bots to guilds. +/// +/// # Examples +/// +/// Create an authorization URL for a bot requiring the "Add Reactions" and +/// "Send Messages" permissions: +/// +/// ```rust +/// extern crate serenity_model; +/// extern crate serenity_oauth; +/// +/// # fn main() { +/// use serenity_model::Permissions; +/// +/// let client_id = 249608697955745802; +/// let required = Permissions::ADD_REACTIONS | Permissions::SEND_MESSAGES; +/// let url = serenity_oauth::utils::bot_authorization_url(client_id, required); +/// +/// // Assert that the expected URL is correct. +/// let expected = "https://discordapp.com/api/oauth2/authorize?client_id=249608697955745802&scope=bot&permissions=2112"; +/// assert_eq!(url, expected); +/// # } +/// ``` +pub fn bot_authorization_url(client_id: u64, permissions: Permissions) + -> String { + format!( + "{}?client_id={}&scope=bot&permissions={}", + BASE_AUTHORIZE_URI, + client_id, + permissions.bits(), + ) +} + +/// Creates a URL for an authorization code grant. +/// +/// This will create a URL to redirect the user to, requesting the given scopes +/// for your client ID. +/// +/// The given `redirect_uri` will automatically be URL encoded. +/// +/// A state _should_ be passed, as recommended by RFC 6749. It is a unique +/// identifier for the user's request. When Discord redirects the user to the +/// given redirect URI, it will append a `state` parameter to the query. It will +/// match the state that you have recorded for that user. If it does not, there +/// was likely a request interception. +/// +/// As well as the callback URL having the same `state` appended in the query +/// parameters, this will also append a `code`. +/// +/// # Examples +/// +/// Produce an authorization code grant URL for your client, requiring the +/// [`Scope::Identify`] and [`Scope::GuildsJoin`] scopes, and an example of a +/// state: +/// +/// **Note**: Please randomly generate this using a crate like `rand`. +/// +/// ```rust +/// use serenity_oauth::Scope; +/// +/// let client_id = 249608697955745802; +/// let scopes = [Scope::GuildsJoin, Scope::Identify]; +/// let state = "15773059ghq9183habn"; +/// let redirect_uri = "https://myapplication.website"; +/// +/// let url = serenity_oauth::utils::authorization_code_grant_url( +/// client_id, +/// &scopes, +/// Some(state), +/// redirect_uri, +/// ); +/// +/// // Assert that the URL is correct. +/// let expected = "https://discordapp.com/api/oauth2/authorize?response_type=code&client_id=249608697955745802&redirect_uri=https%3A%2F%2Fmyapplication.website&scope=guilds.join%20identify&state=15773059ghq9183habn"; +/// assert_eq!(url, expected); +/// ``` +/// +/// [`Scope::GuildsJoin`]: enum.Scope.html#variant.GuildsJoin +/// [`Scope::Identify`]: enum.Scope.html#variant.Identify +pub fn authorization_code_grant_url( + client_id: u64, + scopes: &[Scope], + state: Option<&str>, + redirect_uri: &str, +) -> String { + let mut base = String::from(BASE_AUTHORIZE_URI); + let uri = percent_encoding::percent_encode( + redirect_uri.as_bytes(), + percent_encoding::USERINFO_ENCODE_SET, + ); + + let _ = write!( + base, + "?response_type=code&client_id={}&redirect_uri={}&scope=", + client_id, + uri, + ); + + let scope_count = scopes.len(); + + for (i, scope) in scopes.iter().enumerate() { + let _ = write!(base, "{}", scope); + + if i + 1 < scope_count { + base.push_str("%20"); + } + } + + if let Some(state) = state { + let _ = write!(base, "&state={}", state); + } + + base +} |