From 9ca0e358dc95e7358815886b061288f04a7d29af Mon Sep 17 00:00:00 2001 From: Vika Date: Sun, 10 Jul 2022 19:35:10 +0300 Subject: kittybox-indieauth: init This crate is the base framework-agnostic implementation of all data structures and methods required for IndieAuth protocol. Anything that can deserialize HTTP request payloads with serde can utilize this crate. This is a good candidate to independently release on crates.io when the interface becomes stable enough. --- kittybox-rs/indieauth/Cargo.toml | 18 +++ kittybox-rs/indieauth/src/lib.rs | 257 ++++++++++++++++++++++++++++++++++++ kittybox-rs/indieauth/src/pkce.rs | 73 ++++++++++ kittybox-rs/indieauth/src/scopes.rs | 169 ++++++++++++++++++++++++ 4 files changed, 517 insertions(+) create mode 100644 kittybox-rs/indieauth/Cargo.toml create mode 100644 kittybox-rs/indieauth/src/lib.rs create mode 100644 kittybox-rs/indieauth/src/pkce.rs create mode 100644 kittybox-rs/indieauth/src/scopes.rs (limited to 'kittybox-rs/indieauth') diff --git a/kittybox-rs/indieauth/Cargo.toml b/kittybox-rs/indieauth/Cargo.toml new file mode 100644 index 0000000..222d706 --- /dev/null +++ b/kittybox-rs/indieauth/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "kittybox-indieauth" +version = "0.1.0" +edition = "2021" + +[dev-dependencies] +serde_json = "^1.0.64" # A JSON serialization file format + +[dependencies] +rand = "^0.8.5" # Utilities for random number generation +data-encoding = "^2.3.2" # Efficient and customizable data-encoding functions like base64, base32, and hex +sha2 = "^0.9.8" # SHA-2 series of algorithms for Rust +[dependencies.url] # URL library for Rust, based on the WHATWG URL Standard +version = "^2.2.1" +features = ["serde"] +[dependencies.serde] # A generic serialization/deserialization framework +version = "^1.0.125" +features = ["derive"] diff --git a/kittybox-rs/indieauth/src/lib.rs b/kittybox-rs/indieauth/src/lib.rs new file mode 100644 index 0000000..23b3923 --- /dev/null +++ b/kittybox-rs/indieauth/src/lib.rs @@ -0,0 +1,257 @@ +use serde::{Serialize, Deserialize}; +use url::Url; + +mod scopes; +pub use self::scopes::{Scope, Scopes}; +mod pkce; +pub use self::pkce::{PKCEMethod, PKCEVerifier, PKCEChallenge}; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +pub enum IntrospectionEndpointAuthMethod { + Bearer, + #[serde(rename = "snake_case")] + ClientSecretPost, + #[serde(rename = "snake_case")] + ClientSecretBasic, + #[serde(rename = "snake_case")] + TlsClientAuth, + #[serde(rename = "snake_case")] + SelfSignedTlsClientAuth +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RevocationEndpointAuthMethod { + None +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResponseType { + Code +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GrantType { + AuthorizationCode, + RefreshToken +} + +/// OAuth 2.0 Authorization Server Metadata in application to the IndieAuth protocol. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Metadata { + /// The server's issuer identifier. The issuer identifier is a URL + /// that uses the "https" scheme and has no query or fragment + /// components. The identifier MUST be a prefix of the + /// `indieauth-metadata` URL. + pub issuer: Url, + /// The Authorization Endpoint + pub authorization_endpoint: Url, + /// The Token Endpoint + 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. + #[serde(skip_serializing_if = "Option::is_none")] + pub introspection_endpoint_auth_methods_supported: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub revocation_endpoint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub revocation_endpoint_auth_methods_supported: Option>, + // Note: Scopes isn't used here because this field should be + // serialized as a list, not as a string + pub scopes_supported: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub response_types_supported: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub grant_types_supported: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_documentation: Option, + pub code_challenge_methods_supported: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub authorization_response_iss_parameter_supported: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub userinfo_endpoint: Option +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Profile { + pub name: String, + pub url: Url, + pub photo: Url, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct State(String); +impl State { + fn new() -> Self { + use rand::{Rng, distributions::Alphanumeric}; + let bytes = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(128) + .collect::>(); + Self(String::from_utf8(bytes).unwrap()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthorizationRequest { + pub response_type: ResponseType, + pub client_id: Url, + pub redirect_uri: Url, + pub state: State, + #[serde(flatten)] + pub code_challenge: PKCEChallenge, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub me: Option +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AuthorizationResponse { + pub code: String, + pub state: State, + iss: Url +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct GrantRequest { + grant_type: GrantType, + code: String, + client_id: Url, + redirect_uri: Url, + code_verifier: PKCEVerifier +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +enum GrantResponse { + AccessToken { + me: Url, + #[serde(skip_serializing_if = "Option::is_none")] + profile: Option, + access_token: String, + #[serde(skip_serializing_if = "Option::is_none")] + expires_in: Option, + #[serde(skip_serializing_if = "Option::is_none")] + refresh_token: Option + }, + ProfileUrl { + me: Url, + #[serde(skip_serializing_if = "Option::is_none")] + profile: Option + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenIntrospectionRequest { + pub token: String +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenData { + pub me: Url, + pub client_id: Url, + pub scope: Scopes, + #[serde(skip_serializing_if = "Option::is_none")] + pub exp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub iat: Option +} + +impl TokenData { + pub fn expired(&self) -> bool { + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + self.exp + .map(|exp| SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_secs() >= exp) + .unwrap_or_default() + } + + pub fn expires_at(&self) -> Option { + self.exp.map(|time| { + std::time::UNIX_EPOCH + std::time::Duration::from_secs(time) + }) + } + + pub fn issued_at(&self) -> Option { + self.iat.map(|time| { + std::time::UNIX_EPOCH + std::time::Duration::from_secs(time) + }) + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenIntrospectionResponse { + active: bool, + #[serde(flatten)] + #[serde(skip_serializing_if = "Option::is_none")] + data: Option +} +impl TokenIntrospectionResponse { + pub fn inactive() -> Self { + Self { active: false, data: None } + } + pub fn active(data: TokenData) -> Self { + Self { active: true, data: Some(data) } + } + + pub fn is_active(&self) -> bool { + self.active + } + + pub fn data(&self) -> Option<&TokenData> { + if !self.active { + return None + } + self.data.as_ref() + } +} +impl Default for TokenIntrospectionResponse { + fn default() -> Self { + Self::inactive() + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenRevocationRequest { + pub token: String +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[serde(tag = "error")] +pub enum IndieAuthError { + InvalidRequest, + InvalidToken, + InsufficientScope, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_serialize_indieauth_error() { + assert_eq!( + serde_json::to_value(IndieAuthError::InvalidRequest).unwrap(), + json!({"error": "invalid_request"}) + ); + assert_eq!( + serde_json::to_value(IndieAuthError::InvalidToken).unwrap(), + json!({"error": "invalid_token"}) + ); + assert_eq!( + serde_json::to_value(IndieAuthError::InsufficientScope).unwrap(), + json!({"error": "insufficient_scope"}) + ); + } +} diff --git a/kittybox-rs/indieauth/src/pkce.rs b/kittybox-rs/indieauth/src/pkce.rs new file mode 100644 index 0000000..a0bc291 --- /dev/null +++ b/kittybox-rs/indieauth/src/pkce.rs @@ -0,0 +1,73 @@ +use serde::{Serialize, Deserialize}; +use rand::{Rng, distributions::Alphanumeric}; +use sha2::{Sha256, Digest}; +use data_encoding::BASE64URL; + +#[derive(PartialEq, Eq, Copy, Clone, Debug, Serialize, Deserialize)] +pub enum PKCEMethod { + S256, + Plain +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PKCEVerifier(String); + +impl AsRef for PKCEVerifier { + fn as_ref(&self) -> &str { + self.0.as_str() + } +} +impl ToString for PKCEVerifier { + fn to_string(&self) -> String { + self.0.clone() + } +} + +impl PKCEVerifier { + fn new() -> Self { + let bytes = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(128) + .collect::>(); + Self(String::from_utf8(bytes).unwrap()) + } +} + +#[derive(Eq, PartialEq, Debug, Clone, Serialize, Deserialize)] +pub struct PKCEChallenge { + code_challenge: String, + method: PKCEMethod +} + +impl PKCEChallenge { + fn new(code_verifier: PKCEVerifier, method: PKCEMethod) -> Self { + Self { + code_challenge: match method { + PKCEMethod::S256 => { + let mut hasher = Sha256::new(); + hasher.update(code_verifier.as_ref()); + BASE64URL.encode(&hasher.finalize()) + }, + PKCEMethod::Plain => code_verifier.to_string(), + }, + method + } + } + 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 new file mode 100644 index 0000000..6d1ed7e --- /dev/null +++ b/kittybox-rs/indieauth/src/scopes.rs @@ -0,0 +1,169 @@ +use serde::{ + Serialize, Serializer, + Deserialize, + de::{ + Deserializer, Visitor, + Error as DeserializeError + } +}; + +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Scope { + /// Allows to create posts using Micropub. + Create, + /// Allows to edit posts using Micropub. + Update, + /// Allows to delete posts using Micropub. + Delete, + /// Allows to upload blobs to the media endpoint. + Media, + /// Allows to read feeds via Microsub. + Read, + /// Allows to manage follows via Microsub. + Follow, + /// Allows to mute and unmute users in feeds via Microsub. + Mute, + /// Allows to block and unblock users. + Block, + /// Allows to create and manage feeds via Microsub. + Channels, + /// Allows to request profile information (except email, see Email) + Profile, + /// Allows to receive email in the profile information. + Email, + /// Custom scope not included above. + Custom(String) +} +impl Scope { + pub fn custom(scope: &str) -> Scope { + Scope::Custom(scope.to_string()) + } +} +// TODO consider relying on serde for these conversions +impl AsRef for Scope { + fn as_ref(&self) -> &str { + use Scope::*; + match self { + Create => "create", + Update => "update", + Delete => "delete", + Media => "media", + Read => "read", + Follow => "follow", + Mute => "mute", + Block => "block", + Channels => "channels", + Profile => "profile", + Email => "email", + Custom(s) => s.as_ref() + } + } +} +impl From<&str> for Scope { + fn from(scope: &str) -> Self { + match scope { + "create" => Scope::Create, + "update" => Scope::Update, + "delete" => Scope::Delete, + "media" => Scope::Media, + "read" => Scope::Read, + "follow" => Scope::Follow, + "mute" => Scope::Mute, + "block" => Scope::Block, + "channels" => Scope::Channels, + "profile" => Scope::Profile, + "email" => Scope::Email, + other => Scope::custom(other) + } + } +} +#[derive(Debug, Clone)] +pub struct Scopes(Vec); +impl Scopes { + pub fn new(scopes: Vec) -> Self { + Self(scopes) + } + pub fn has(&self, scope: &Scope) -> bool { + self.0.iter().any(|s| s == scope) + } + pub fn has_all(&self, scopes: &[Scope]) -> bool { + scopes.iter() + .map(|s1| scopes.iter().any(|s2| s1 == s2)) + .all(|s| s) + } +} +impl AsRef<[Scope]> for Scopes { + fn as_ref(&self) -> &[Scope] { + self.0.as_ref() + } +} +impl ToString for Scopes { + fn to_string(&self) -> String { + self.0.iter() + .map(|s| s.as_ref()) + .fold(String::new(), |a, s| if a.is_empty() { + s.to_string() + } else { + a + " " + s + }) + } +} +impl Serialize for Scopes { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer + { + serializer.serialize_str(&self.to_string()) + } +} +struct ScopeVisitor; +impl<'de> Visitor<'de> for ScopeVisitor { + type Value = Scopes; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string of space-separated OAuth2 scopes") + } + + fn visit_str(self, value: &str) -> Result + where + E: DeserializeError + { + Ok(Scopes(value.split_ascii_whitespace() + .map(Scope::from) + .collect::>())) + } +} +impl<'de> Deserialize<'de> for Scopes { + + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de> + { + deserializer.deserialize_str(ScopeVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_vec_scope() { + let scopes = vec![ + Scope::Create, Scope::Update, Scope::Delete, + Scope::Media, + Scope::custom("kittybox_internal_access") + ]; + + let scope_serialized = serde_json::to_value( + Scopes::new(scopes.clone()) + ).unwrap(); + let scope_str = scope_serialized.as_str().unwrap(); + assert_eq!(scope_str, "create update delete media kittybox_internal_access"); + + assert!(serde_json::from_value::(scope_serialized).unwrap().has_all(&scopes)) + + } + +} -- cgit 1.4.1