about summary refs log tree commit diff
path: root/kittybox-rs/indieauth
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2022-07-10 19:35:10 +0300
committerVika <vika@fireburn.ru>2022-07-10 19:35:10 +0300
commit9ca0e358dc95e7358815886b061288f04a7d29af (patch)
treebc19ac6071d75f1ae58069e577fc02692aa87c8b /kittybox-rs/indieauth
parent0ee2072d40dc0b88a7f475a25dfc790d49546a5f (diff)
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.
Diffstat (limited to 'kittybox-rs/indieauth')
-rw-r--r--kittybox-rs/indieauth/Cargo.toml18
-rw-r--r--kittybox-rs/indieauth/src/lib.rs257
-rw-r--r--kittybox-rs/indieauth/src/pkce.rs73
-rw-r--r--kittybox-rs/indieauth/src/scopes.rs169
4 files changed, 517 insertions, 0 deletions
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<Vec<IntrospectionEndpointAuthMethod>>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub revocation_endpoint: Option<Url>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub revocation_endpoint_auth_methods_supported: Option<Vec<RevocationEndpointAuthMethod>>,
+    // Note: Scopes isn't used here because this field should be
+    // serialized as a list, not as a string
+    pub scopes_supported: Vec<Scope>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub response_types_supported: Option<Vec<ResponseType>>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub grant_types_supported: Option<Vec<GrantType>>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub service_documentation: Option<Url>,
+    pub code_challenge_methods_supported: Vec<PKCEMethod>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub authorization_response_iss_parameter_supported: Option<bool>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub userinfo_endpoint: Option<Url>
+}
+
+#[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<String>
+}
+
+#[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::<Vec<u8>>();
+        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<Scopes>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub me: Option<Url>
+}
+
+#[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<Profile>,
+        access_token: String,
+        #[serde(skip_serializing_if = "Option::is_none")]
+        expires_in: Option<usize>,
+        #[serde(skip_serializing_if = "Option::is_none")]
+        refresh_token: Option<String>
+    },
+    ProfileUrl {
+        me: Url,
+        #[serde(skip_serializing_if = "Option::is_none")]
+        profile: Option<Profile>
+    }
+}
+
+#[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<u64>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub iat: Option<u64>
+}
+
+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<std::time::SystemTime> {
+        self.exp.map(|time| {
+            std::time::UNIX_EPOCH + std::time::Duration::from_secs(time)
+        })
+    }
+    
+    pub fn issued_at(&self) -> Option<std::time::SystemTime> {
+        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<TokenData>
+}
+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<str> 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::<Vec<u8>>();
+        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<str> 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<Scope>);
+impl Scopes {
+    pub fn new(scopes: Vec<Scope>) -> 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    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<E>(self, value: &str) -> Result<Self::Value, E>
+    where
+        E: DeserializeError
+    {
+        Ok(Scopes(value.split_ascii_whitespace()
+                  .map(Scope::from)
+                  .collect::<Vec<Scope>>()))
+    }
+}
+impl<'de> Deserialize<'de> for Scopes {
+    
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    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::<Scopes>(scope_serialized).unwrap().has_all(&scopes))
+        
+    }
+
+}