From d381b12b3ae22b0eca6b24b9435cc28c094eaa60 Mon Sep 17 00:00:00 2001 From: Vika Date: Fri, 22 Jul 2022 08:41:27 +0300 Subject: kittybox-indieauth: document the entire crate I am procrastinating very hard right now. --- kittybox-rs/indieauth/src/lib.rs | 296 +++++++++++++++++++++++++++++++++++- kittybox-rs/indieauth/src/pkce.rs | 50 +++--- kittybox-rs/indieauth/src/scopes.rs | 10 ++ 3 files changed, 333 insertions(+), 23 deletions(-) (limited to 'kittybox-rs') diff --git a/kittybox-rs/indieauth/src/lib.rs b/kittybox-rs/indieauth/src/lib.rs index 5896ebb..826da4e 100644 --- a/kittybox-rs/indieauth/src/lib.rs +++ b/kittybox-rs/indieauth/src/lib.rs @@ -1,3 +1,23 @@ +#![deny(missing_docs)] +#![forbid(rustdoc::broken_intra_doc_links)] +//! A library of useful structs and helpers to implement [IndieAuth +//! version 20220212][indieauth]. +//! +//! This crate is completely network-agnostic, which means it can be +//! used with both sync and async web frameworks, and even on the +//! client side to implement identity consumers. +//! +//! ## Integration with web frameworks +//! +//! For convenience, helpers for [`axum`], the web framework Kittybox +//! happens to use, are provided. Enable the `axum` feature to use +//! them. +//! +//! The author is happy to accept patches to add more +//! framework-specific helpers. +//! +//! [indieauth]: https://indieauth.spec.indieweb.org/20220212/ +//! [`axum`]: https://github.com/tokio-rs/axum use serde::{Serialize, Deserialize}; use url::Url; @@ -6,39 +26,96 @@ pub use self::scopes::{Scope, Scopes}; mod pkce; pub use self::pkce::{PKCEMethod, PKCEVerifier, PKCEChallenge}; +/// Authentication methods supported by the introspection endpoint. +/// Note that authentication at the introspection endpoint is +/// mandatory. #[derive(Copy, Clone, Debug, Serialize, Deserialize)] pub enum IntrospectionEndpointAuthMethod { + /// `Authorization` header with a `Bearer` token. Bearer, + /// A token passed as part of a POST request. #[serde(rename = "snake_case")] ClientSecretPost, + /// Username and password passed using HTTP Basic authentication. #[serde(rename = "snake_case")] ClientSecretBasic, + /// TLS client auth with a certificate signed by a valid CA. #[serde(rename = "snake_case")] TlsClientAuth, + /// TLS client auth with a self-signed certificate. #[serde(rename = "snake_case")] SelfSignedTlsClientAuth } +/// Authentication methods supported by the revocation endpoint. +/// +/// The intent of the IndieAuth revocation endpoints is to quickly +/// revoke leaked tokens. As it requires posession of a token, no +/// authentication is neccesary to protect tokens. A well-intentioned +/// person discovering a leaked token could quickly revoke it without +/// disturbing anyone. #[derive(Copy, Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RevocationEndpointAuthMethod { + /// No authentication is required to access an endpoint declaring + /// this value. None } +/// The response types supported by the authorization endpoint. #[derive(Copy, Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ResponseType { + /// An authorization code will be issued if this response type is + /// requested. Code } +/// Grant types that are described in the IndieAuth spec. +/// +/// This type is strictly for usage in the [`Metadata`] response. For +/// grant requests and responses, see [`GrantRequest`] and +/// [`GrantResponse`]. #[derive(Copy, Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum GrantType { + /// The authorization code grant, allowing to exchange an + /// authorization code for a confirmation of identity or an access + /// token. AuthorizationCode, + /// The refresh token grant, allowing to exchange a refresh token + /// for a fresh access token and a new refresh token, to + /// facilitate long-term access. RefreshToken } /// OAuth 2.0 Authorization Server Metadata in application to the IndieAuth protocol. +/// +/// Your metadata endpoint should return this as a response. +/// +/// ```rust +/// use kittybox_indieauth::{ +/// Metadata, IntrospectionEndpointAuthMethod, RevocationEndpointAuthMethod, +/// ResponseType, Scope, GrantType, PKCEMethod +/// }; +/// +/// let metadata = Metadata { +/// issuer: "https://indieauth.example.com/".parse().unwrap(), +/// authorization_endpoint: "https://indieauth.example.com/auth".parse().unwrap(), +/// token_endpoint: "https://indieauth.example.com/token".parse().unwrap(), +/// introspection_endpoint: "https://indieauth.example.com/introspection".parse().unwrap(), +/// introspection_endpoint_auth_methods_supported: Some(vec![IntrospectionEndpointAuthMethod::Bearer]), +/// revocation_endpoint: Some("https://indieauth.example.com/revoke".parse().unwrap()), +/// revocation_endpoint_auth_methods_supported: Some(vec![RevocationEndpointAuthMethod::None]), +/// scopes_supported: Some(vec![Scope::Create, Scope::Update, Scope::custom("manage_tokens")]), +/// response_types_supported: Some(vec![ResponseType::Code]), +/// grant_types_supported: Some(vec![GrantType::AuthorizationCode, GrantType::RefreshToken]), +/// service_documentation: Some("https://indieauth.spec.indieweb.org/".parse().unwrap()), +/// code_challenge_methods_supported: vec![PKCEMethod::S256], +/// authorization_response_iss_parameter_supported: Some(true), +/// userinfo_endpoint: Some("https://indieauth.example.com/userinfo".parse().unwrap()) +/// }; +/// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Metadata { /// The server's issuer identifier. The issuer identifier is a URL @@ -52,35 +129,92 @@ pub struct Metadata { pub token_endpoint: Url, /// The Introspection Endpoint pub introspection_endpoint: Url, - /// JSON array containing a list of client authentication methods supported by this introspection endpoint. + /// JSON array containing a list of client authentication methods + /// supported by this introspection endpoint. #[serde(skip_serializing_if = "Option::is_none")] pub introspection_endpoint_auth_methods_supported: Option>, + /// The Revocation Endpoint #[serde(skip_serializing_if = "Option::is_none")] pub revocation_endpoint: Option, + /// JSON array containing the value + /// [`RevocationEndpointAuthMethod::None`]. If a revocation endpoint + /// is provided, this property should also be provided with the + /// value `vec![RevocationEndpointAuthMethod::None]`, since the + /// omission of this value defaults to `client_secret_basic` + /// according to [RFC8414]. + /// + /// [RFC8414]: https://www.rfc-editor.org/rfc/rfc8414 #[serde(skip_serializing_if = "Option::is_none")] pub revocation_endpoint_auth_methods_supported: Option>, + /// JSON array containing scope values supported by the IndieAuth + /// server. Servers MAY choose not to advertise some supported + /// scope values even when this parameter is used. // Note: Scopes isn't used here because this field should be // serialized as a list, not as a string #[serde(skip_serializing_if = "Option::is_none")] pub scopes_supported: Option>, + /// JSON array containing the response_type values supported. This + /// differs from [RFC8414] in that this parameter is OPTIONAL and + /// that, if omitted, the default is [`ResponseType::Code`]. + /// + /// [RFC8414]: https://www.rfc-editor.org/rfc/rfc8414 #[serde(skip_serializing_if = "Option::is_none")] pub response_types_supported: Option>, + /// JSON array containing grant type values supported. If omitted, + /// the default value differs from [RFC8414] and is + /// `authorization_code`. + /// + /// [RFC8414]: https://www.rfc-editor.org/rfc/rfc8414 #[serde(skip_serializing_if = "Option::is_none")] pub grant_types_supported: Option>, + /// URL of a page containing human-readable information that + /// developers might need to know when using the server. This + /// might be a link to the IndieAuth spec or something more + /// personal to your implementation. #[serde(skip_serializing_if = "Option::is_none")] pub service_documentation: Option, + /// JSON array containing the methods supported for PKCE. This + /// parameter differs from [RFC8414] in that it is not optional as + /// PKCE is *REQUIRED*. + /// + /// [RFC8414]: https://www.rfc-editor.org/rfc/rfc8414 pub code_challenge_methods_supported: Vec, + /// Boolean parameter indicating whether the authorization server + /// provides the iss parameter. If omitted, the default value is + /// false. As the iss parameter is REQUIRED, this is provided for + /// compatibility with OAuth 2.0 servers implementing the + /// parameter. #[serde(skip_serializing_if = "Option::is_none")] pub authorization_response_iss_parameter_supported: Option, + /// The User Info Endpoint #[serde(skip_serializing_if = "Option::is_none")] pub userinfo_endpoint: Option } +#[cfg(feature = "axum")] +impl axum_core::response::IntoResponse for Metadata { + fn into_response(self) -> axum_core::response::Response { + use http::StatusCode; + + (StatusCode::OK, + [("Content-Type", "application/json")], + serde_json::to_vec(&self).unwrap()) + .into_response() + } +} + +/// User profile to be returned from the userinfo endpoint and when +/// the `profile` scope was requested. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Profile { + /// User's chosen name. pub name: String, + /// User's profile page. Fetching it may reveal an `h-card`. pub url: Url, + /// User's profile picture suitable to represent them. pub photo: Url, + /// User's email, if they've chosen to reveal it. This is guarded + /// by the `email` scope. #[serde(skip_serializing_if = "Option::is_none")] pub email: Option } @@ -97,9 +231,15 @@ impl axum_core::response::IntoResponse for Profile { } } +/// A state string comprised of alphanumeric characters to protect +/// from CSRF attacks. +/// +/// There is no reason to inspect the string itself except to ensure +/// it hasn't been tampered with. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct State(String); impl State { + /// Generate a random state string of 128 bytes in length. fn new() -> Self { use rand::{Rng, distributions::Alphanumeric}; let bytes = rand::thread_rng() @@ -110,60 +250,165 @@ impl State { } } +/// The authorization request that should be affixed to the URL of an +/// authorization endpoint to start the IndieAuth ceremony. +/// +/// ```rust +/// use kittybox_indieauth::{ +/// AuthorizationRequest, ResponseType, State, +/// PKCEChallenge, PKCEVerifier, PKCEMethod +/// }; +/// +/// // Save that for later, it'll come in handy +/// let verifier = PKCEVerifier::new(); +/// +/// let request = AuthorizationRequest { +/// response_type: ResponseType::Code, +/// client_id: "https://kittybox.fireburn.ru/companion/native".parse().unwrap(), +/// redirect_uri: "https://kittybox.fireburn.ru/companion/native/redirect".parse().unwrap(), +/// state: State::new(), +/// code_challenge: PKCEChallenge::new(verifier, PKCEMethod::default()), +/// scope: Some(Scopes::new(vec![Scope::Create, Scope::Update, Scope::Delete, Scope::Media])), +/// me: Some("https://fireburn.ru/".parse().unwrap()) +/// }; +/// +/// let mut url: url::Url = "https://fireburn.ru/.kittybox/indieauth/auth" +/// .parse() +/// .unwrap(); +/// +/// authorization_endpoint.set_query(serde_urlencoded::to_string(request).unwrap()); +/// +/// // Open a user's browser to navigate to the authorization endpoint page... +/// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthorizationRequest { + /// The response type expected to this request. pub response_type: ResponseType, + /// The homepage of the client. It must be fetched to show + /// metadata and check the redirect URI's authenticity. pub client_id: Url, + /// The URI that the user will be redirected to in case they + /// approve the authentication request. A query string containing + /// the response is affixed to it. pub redirect_uri: Url, + /// A random state to protect from CSRF attacks. The server should + /// return this string unmodified. pub state: State, + /// A PKCE challenge neccesary to protect from authorization code + /// injection and CSRF attacks. #[serde(flatten)] pub code_challenge: PKCEChallenge, + /// An array of scopes that are requested for a token. If no + /// scopes are provided, a token will not be issued. #[serde(skip_serializing_if = "Option::is_none")] pub scope: Option, + /// The URL that user entered. The authorization endpoint MAY use + /// it as a hint of which user is attempting to sign in, and to + /// indicate which profile URL the client is expecting in the + /// resulting profile URL response or access token response. #[serde(skip_serializing_if = "Option::is_none")] pub me: Option } +/// The authorization response that must be appended to the +/// [`AuthorizationRequest::redirect_uri`]'s query string. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthorizationResponse { + /// The authorization code generated by the authorization + /// endpoint. The code MUST expire shortly after it is issued to + /// mitigate the risk of leaks, and MUST be valid for only one + /// use. A maximum lifetime of 10 minutes is recommended. See + /// [OAuth 2.0 Section 4.1.2][oauth2-sec-4.1.2] for additional + /// requirements on the authorization code. + /// + /// [oauth2-sec-4.1.2]: https://tools.ietf.org/html/rfc6749#section-4.1.2 pub code: String, + /// The state parameter from the [AuthorizationRequest], + /// unmodified. pub state: State, + /// The issuer identifier for client validation. + /// + /// Clients MUST verify this matches the [`Metadata::issuer`] + /// parameter provided by the Server [Metadata] endpoint during + /// Discovery as outlined in [OAuth 2.0 Authorization Server + /// Issuer Identification][oauth2-iss]. If the value does not + /// match the expected issuer identifier, clients MUST reject the + /// authorization response and MUST NOT proceed with the + /// authorization grant. For error responses, clients MUST NOT + /// assume that the error originates from the intended + /// authorization server. + /// + /// [oauth2-iss]: https://www.ietf.org/archive/id/draft-ietf-oauth-iss-auth-resp-02.html pub iss: Url } +/// A grant request that continues the IndieAuth ceremony. #[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] #[serde(tag = "grant_type")] #[serde(rename_all = "snake_case")] pub enum GrantRequest { + /// Use an authorization code to receive identity verification + /// and/or an access token. AuthorizationCode { + /// The code from [`AuthorizationResponse`]. code: String, + /// Client ID that this grant belongs to. client_id: Url, + /// Redirect URI that was used to receive the grant. redirect_uri: Url, + /// The PKCE code verifier that was used to create the code + /// challenge. code_verifier: PKCEVerifier }, + /// Use a refresh token to get a fresh access token and a new + /// matching refresh token. RefreshToken { + /// The refresh token that was issued before. refresh_token: String, + /// The client ID to which the token belongs to. client_id: url::Url, + /// A list of scopes, not exceeding the already-granted scope, + /// that can be passed to further restrict the scopes on the + /// new token. + /// + /// This cannot be used to gain new scopes -- you need to + /// start over if you need new scopes from the user. scope: Option } } +/// The response to a successful [`GrantRequest`]. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum GrantResponse { + /// An access token response, containing an access token, a refresh + /// token (if the identity provider supports them) and the profile + /// (if access was granted to the profile data). AccessToken { + /// The URL for the user this token corresponds to. me: Url, + /// The user's profile information, if it was requested. #[serde(skip_serializing_if = "Option::is_none")] profile: Option, + /// The access token that can be used to access protected resources. access_token: String, + /// The duration in which the access token expires, represented in seconds. // TODO replace with std::time::Duration #[serde(skip_serializing_if = "Option::is_none")] expires_in: Option, + /// The refresh token, if it was issued. #[serde(skip_serializing_if = "Option::is_none")] refresh_token: Option }, + /// A profile URL response, that only contains the profile URL and + /// the profile, if it was requested. + /// + /// This is suitable for confirming the identity of the user, but + /// no more than that. ProfileUrl { + /// The authenticated user's URL. me: Url, + /// The user's profile information, if it was requested. #[serde(skip_serializing_if = "Option::is_none")] profile: Option } @@ -189,29 +434,48 @@ impl axum_core::response::IntoResponse for GrantResponse { /// This type mostly exists for ease-of-use with serde. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] +#[allow(missing_docs)] pub enum RequestMaybeAuthorizationEndpoint { Authorization(AuthorizationRequest), Grant(GrantRequest) } +/// A token introspection request that can be handled by the token +/// introspection endpoint. +/// +/// Note that this request doesn't contain authentication data, which +/// is commonly transmitted out-of-band (e.g. via the `Authorization` +/// header). #[derive(Debug, Serialize, Deserialize)] pub struct TokenIntrospectionRequest { + /// The token for which data was requested. pub token: String } +/// Data for a token that will be returned by the introspection +/// endpoint (and can also be used internally by the resource server +/// if it is part of a monolith with the identity provider). #[derive(Debug, Serialize, Deserialize)] pub struct TokenData { + /// The user this token corresponds to. pub me: Url, + /// The client ID for the client that this token was issued to. pub client_id: Url, + /// Scope that was granted to this token. pub scope: Scopes, + /// The expiration date for this token, measured in seconds from + /// the Unix time epoch (1970-01-01 00:00:00). // TODO replace these two with std::time::SystemTime #[serde(skip_serializing_if = "Option::is_none")] pub exp: Option, + /// The issue date, represented in the same format as the + /// [`exp`][TokenData::exp] field. #[serde(skip_serializing_if = "Option::is_none")] pub iat: Option } impl TokenData { + /// Check if the token in question expired. pub fn expired(&self) -> bool { use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -223,12 +487,13 @@ impl TokenData { .unwrap_or_default() } + /// Return a timestamp at which the token is not considered valid anymore. pub fn expires_at(&self) -> Option { self.exp.map(|time| { std::time::UNIX_EPOCH + std::time::Duration::from_secs(time) }) } - + /// Return a timestamp describing when the token was issued. pub fn issued_at(&self) -> Option { self.iat.map(|time| { std::time::UNIX_EPOCH + std::time::Duration::from_secs(time) @@ -240,6 +505,11 @@ impl TokenData { // internally by Option. But the IndieAuth standard // requires the "active" field to be present. I can't do anything // about it. +/// The introspection response that the introspection endpoint must +/// return. +/// +/// It is recommended to use the [`From`][`std::convert::From`] trait +/// to convert from `Option` for ergonomics. #[derive(Debug, Serialize, Deserialize)] pub struct TokenIntrospectionResponse { active: bool, @@ -250,17 +520,20 @@ pub struct TokenIntrospectionResponse { // These wrappers and impls should take care of making use of this // type as painless as possible. impl TokenIntrospectionResponse { + /// Indicate that this token is not valid. pub fn inactive() -> Self { Self { active: false, data: None } } + /// Indicate that this token is valid, and provide data about it. pub fn active(data: TokenData) -> Self { Self { active: true, data: Some(data) } } - + /// Check if the endpoint reports this token as valid. pub fn is_active(&self) -> bool { self.active } + /// Get data contained in the response, if the token is valid. pub fn data(&self) -> Option<&TokenData> { if !self.active { return None @@ -296,9 +569,11 @@ impl axum_core::response::IntoResponse for TokenIntrospectionResponse { } } - +/// A request for revoking a token. There is no response beyond `HTTP +/// 200 OK`. #[derive(Debug, Serialize, Deserialize)] pub struct TokenRevocationRequest { + /// The token that needs to be revoked in case it is valid. pub token: String } @@ -307,11 +582,15 @@ pub struct TokenRevocationRequest { #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ResourceErrorKind { - InvalidRequest, + /// The provided token was invalid. InvalidToken, + /// The scope on the token was insufficient to perform the + /// requested operation. InsufficientScope, } +/// Various kinds of errors that could occur when performing the +/// IndieAuth ceremony. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ErrorKind { @@ -367,13 +646,18 @@ impl std::fmt::Display for ErrorKind { } } - +/// An error that can be returned when performing the IndieAuth ceremony. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Error { + /// Type of an error. #[serde(rename = "error")] pub kind: ErrorKind, + /// Human-friendly description of an error, suitable for a + /// developer to read while debugging. #[serde(rename = "error_description")] pub msg: Option, + /// An URL to documentation describing what went wrong and how to + /// fix it. pub error_uri: Option } diff --git a/kittybox-rs/indieauth/src/pkce.rs b/kittybox-rs/indieauth/src/pkce.rs index 6dabcc3..249917e 100644 --- a/kittybox-rs/indieauth/src/pkce.rs +++ b/kittybox-rs/indieauth/src/pkce.rs @@ -3,12 +3,22 @@ use rand::{Rng, distributions::Alphanumeric}; use sha2::{Sha256, Digest}; use data_encoding::BASE64URL; -#[derive(PartialEq, Eq, Copy, Clone, Debug, Serialize, Deserialize)] +/// Methods to use for PKCE challenges. +#[derive(PartialEq, Eq, Copy, Clone, Debug, Serialize, Deserialize, /*Default*/)] pub enum PKCEMethod { + /// Base64-encoded SHA256 hash of an ASCII string. + //#[default] S256, + /// Plain string by itself. Please don't use this. Plain } - +// manual impl until Rust 1.62 hits nixos-unstable +impl Default for PKCEMethod { + fn default() -> Self { PKCEMethod::S256 } +} +/// A PKCE verifier string that should be kept in secret until the end +/// of the authentication ceremony, where it is revealed to prove that +/// the one who uses the grant is the same entity who it was given to. #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub struct PKCEVerifier(pub(super) String); @@ -24,7 +34,9 @@ impl ToString for PKCEVerifier { } impl PKCEVerifier { - fn new() -> Self { + /// Generate a new PKCE verifier string of 128 bytes in length. + #[allow(clippy::new_without_default)] + pub fn new() -> Self { let bytes = rand::thread_rng() .sample_iter(&Alphanumeric) .take(128) @@ -33,6 +45,9 @@ impl PKCEVerifier { } } +/// A PKCE challenge as described in [RFC7636]. +/// +/// [RFC7636]: https://tools.ietf.org/html/rfc7636 #[derive(Eq, PartialEq, Debug, Clone, Serialize, Deserialize)] pub struct PKCEChallenge { code_challenge: String, @@ -40,6 +55,8 @@ pub struct PKCEChallenge { } impl PKCEChallenge { + /// Create a new challenge from a [PKCEVerifier] using a certain + /// [PKCEMethod]. pub fn new(code_verifier: PKCEVerifier, method: PKCEMethod) -> Self { Self { code_challenge: match method { @@ -54,22 +71,21 @@ impl PKCEChallenge { } } + /// Verify that the [PKCEVerifier] corresponds to this challenge, + /// by creating a second challenge string and comparing it against + /// this challenge data. + /// + /// ```rust + /// use kittybox_indieauth::{PKCEVerifier, PKCEMethod, PKCEChallenge}; + /// + /// let verifier = PKCEVerifier::new(); + /// let challenge = PKCEChallenge::new(verifier.clone(), PKCEMethod::default()); + /// // Meanwhile, at the token endpoint, in the end of the ceremony... + /// // ...the challenge gets retrieved from the stored data and verified + /// assert!(challenge.verify(verifier)) + /// ``` #[must_use] pub fn verify(&self, code_verifier: PKCEVerifier) -> bool { Self::new(code_verifier, self.method) == *self } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_pkce() { - let verifier = PKCEVerifier::new(); - let challenge = PKCEChallenge::new(verifier.clone(), PKCEMethod::S256); - - assert!(challenge.verify(verifier)); - } - -} diff --git a/kittybox-rs/indieauth/src/scopes.rs b/kittybox-rs/indieauth/src/scopes.rs index 18ebfbd..ae039a6 100644 --- a/kittybox-rs/indieauth/src/scopes.rs +++ b/kittybox-rs/indieauth/src/scopes.rs @@ -9,6 +9,7 @@ use serde::{ } }; +/// Various scopes that can be requested through IndieAuth. #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Scope { @@ -38,6 +39,7 @@ pub enum Scope { Custom(String) } impl Scope { + /// Create a custom scope from a string slice. pub fn custom(scope: &str) -> Scope { Scope::Custom(scope.to_string()) } @@ -81,20 +83,28 @@ impl From<&str> for Scope { } } } + +/// A list of scopes that serializes to a space-separated string instead of a list. +/// +/// OAuth2 is weird, don't ask me why it's a thing. #[derive(PartialEq, Eq, Debug, Clone)] pub struct Scopes(Vec); impl Scopes { + /// Create a list of scopes from a vector of scopes. pub fn new(scopes: Vec) -> Self { Self(scopes) } + /// Ensure a certain scope is listed in the scope list. pub fn has(&self, scope: &Scope) -> bool { self.0.iter().any(|s| s == scope) } + /// Ensure all of the requested scopes are in the list. pub fn has_all(&self, scopes: &[Scope]) -> bool { scopes.iter() .map(|s1| self.iter().any(|s2| s1 == s2)) .all(|s| s) } + /// Transform this into an iterator over individual scopes. pub fn iter(&self) -> std::slice::Iter<'_, Scope> { self.0.iter() } -- cgit 1.4.1