about summary refs log tree commit diff
path: root/indieauth
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2023-07-29 21:59:56 +0300
committerVika <vika@fireburn.ru>2023-07-29 21:59:56 +0300
commit0617663b249f9ca488e5de652108b17d67fbaf45 (patch)
tree11564b6c8fa37bf9203a0a4cc1c4e9cc088cb1a5 /indieauth
parent26c2b79f6a6380ae3224e9309b9f3352f5717bd7 (diff)
downloadkittybox-0617663b249f9ca488e5de652108b17d67fbaf45.tar.zst
Moved the entire Kittybox tree into the root
Diffstat (limited to 'indieauth')
-rw-r--r--indieauth/Cargo.toml31
-rw-r--r--indieauth/src/lib.rs773
-rw-r--r--indieauth/src/pkce.rs132
-rw-r--r--indieauth/src/scopes.rs208
4 files changed, 1144 insertions, 0 deletions
diff --git a/indieauth/Cargo.toml b/indieauth/Cargo.toml
new file mode 100644
index 0000000..d6bc1fe
--- /dev/null
+++ b/indieauth/Cargo.toml
@@ -0,0 +1,31 @@
+[package]
+name = "kittybox-indieauth"
+version = "0.1.0"
+edition = "2021"
+
+[features]
+default = []
+axum = ["axum-core", "serde_json", "http"]
+
+[dev-dependencies]
+serde_json = "^1.0.64"       # A JSON serialization file format
+serde_urlencoded = "^0.7.0"  # `x-www-form-urlencoded` meets Serde
+[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.10.7"              # 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.170"
+features = ["derive"]
+[dependencies.axum-core]
+version = "^0.3.4"
+optional = true
+[dependencies.serde_json]
+version = "^1.0.64"
+optional = true
+[dependencies.http]
+version = "^0.2.7"
+optional = true
\ No newline at end of file
diff --git a/indieauth/src/lib.rs b/indieauth/src/lib.rs
new file mode 100644
index 0000000..a60cc42
--- /dev/null
+++ b/indieauth/src/lib.rs
@@ -0,0 +1,773 @@
+#![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;
+
+mod scopes;
+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
+}
+// TODO serde_variant
+impl ResponseType {
+    /// Return the response type as it would appear in serialized form.
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            ResponseType::Code => "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
+    /// 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>>,
+    /// The Revocation Endpoint
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub revocation_endpoint: Option<Url>,
+    /// 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<Vec<RevocationEndpointAuthMethod>>,
+    /// 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<Vec<Scope>>,
+    /// 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<Vec<ResponseType>>,
+    /// 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<Vec<GrantType>>,
+    /// 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<Url>,
+    /// 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<PKCEMethod>,
+    /// 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<bool>,
+    /// The User Info Endpoint
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub userinfo_endpoint: Option<Url>
+}
+
+#[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.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub name: Option<String>,
+    /// User's profile page. Fetching it may reveal an `h-card`.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub url: Option<Url>,
+    /// User's profile picture suitable to represent them.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub photo: Option<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<String>
+}
+
+#[cfg(feature = "axum")]
+impl axum_core::response::IntoResponse for Profile {
+    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()
+    }
+}
+
+/// 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.
+    pub 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())
+    }
+}
+impl AsRef<str> for State {
+    fn as_ref(&self) -> &str {
+        self.0.as_str()
+    }
+}
+
+/// 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,
+///     Scopes, Scope,
+///     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();
+///
+/// url.set_query(Some(&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<Scopes>,
+    /// 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<Url>
+}
+
+/// 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<Scopes>
+    }
+}
+
+/// Token type, as described in [RFC6749][].
+///
+/// [RFC6749]: https://www.rfc-editor.org/rfc/rfc6749#section-7.1
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum TokenType {
+    /// A Bearer token described in [RFC6750][]. As far as the author
+    /// of this library is concerned, this is the only type that
+    /// IndieAuth uses.
+    ///
+    /// [RFC6750]: https://www.rfc-editor.org/rfc/rfc6750
+    Bearer
+}
+
+/// 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,
+        /// Token type. Required by OAuth2, not mentioned in
+        /// IndieAuth. Confirmed as erroneous.
+        token_type: TokenType,
+        /// Scopes. REQUIRED if different from what was
+        /// requested. Absence from IndieAuth spec confirmed as
+        /// erroneous.
+        scope: Option<Scopes>,
+        /// The user's profile information, if it was requested.
+        #[serde(skip_serializing_if = "Option::is_none")]
+        profile: Option<Profile>,
+        /// 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<u64>,
+        /// The refresh token, if it was issued.
+        #[serde(skip_serializing_if = "Option::is_none")]
+        refresh_token: Option<String>
+    },
+    /// 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<Profile>
+    }
+}
+
+#[cfg(feature = "axum")]
+impl axum_core::response::IntoResponse for GrantResponse {
+    fn into_response(self) -> axum_core::response::Response {
+        use http::StatusCode;
+
+        (StatusCode::OK,
+         [("Content-Type", "application/json"),
+          ("Cache-Control", "no-store"),
+          ("Pragma", "no-cache")
+         ],
+         serde_json::to_vec(&self).unwrap())
+            .into_response()
+    }
+}
+
+/// Describes requests that the authorization endpoint might want to handle.
+///
+/// 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<u64>,
+    /// The issue date, represented in the same format as the
+    /// [`exp`][TokenData::exp] field.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub iat: Option<u64>
+}
+
+impl TokenData {
+    /// Check if the token in question expired.
+    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()
+    }
+
+    /// Return a timestamp at which the token is not considered valid anymore.
+    pub fn expires_at(&self) -> Option<std::time::SystemTime> {
+        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<std::time::SystemTime> {
+        self.iat.map(|time| {
+            std::time::UNIX_EPOCH + std::time::Duration::from_secs(time)
+        })
+    }
+
+    /// Check if a certain scope is allowed for this token.
+    pub fn check_scope(&self, scope: &Scope) -> bool {
+        self.scope.has(scope)
+    }
+}
+
+// I don't like this type, because it could've been represented
+// internally by Option<TokenData>. 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<TokenData>` for ergonomics.
+#[derive(Debug, Serialize, Deserialize)]
+pub struct TokenIntrospectionResponse {
+    active: bool,
+    #[serde(flatten)]
+    #[serde(skip_serializing_if = "Option::is_none")]
+    data: Option<TokenData>
+}
+// 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
+        }
+        self.data.as_ref()
+    }
+}
+impl Default for TokenIntrospectionResponse {
+    fn default() -> Self {
+        Self::inactive()
+    }
+}
+impl From<Option<TokenData>> for TokenIntrospectionResponse {
+    fn from(data: Option<TokenData>) -> Self {
+        Self { active: data.is_some(), data }
+    }
+}
+impl From<TokenIntrospectionResponse> for Option<TokenData> {
+    fn from(response: TokenIntrospectionResponse) -> Option<TokenData> {
+        response.data
+    }
+}
+
+#[cfg(feature = "axum")]
+impl axum_core::response::IntoResponse for TokenIntrospectionResponse {
+    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()
+    }
+}
+
+/// 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
+}
+
+/// Types of errors that a resource server (IndieAuth consumer) can
+/// throw when authentication goes wrong.
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ResourceErrorKind {
+    /// 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 {
+    /// The request is missing a required parameter, includes an
+    /// unsupported parameter value (other than grant type), repeats a
+    /// parameter, includes multiple credentials, utilizes more than
+    /// one mechanism for authenticating the client, or is otherwise
+    /// malformed.
+    InvalidRequest,
+    /// Client authentication failed (e.g., unknown client, no client
+    /// authentication included, or unsupported authentication
+    /// method).  The authorization server MAY return an HTTP 401
+    /// (Unauthorized) status code to indicate which HTTP
+    /// authentication schemes are supported.  If the client attempted
+    /// to authenticate via the "Authorization" request header field,
+    /// the authorization server MUST respond with an HTTP 401
+    /// (Unauthorized) status code and include the "WWW-Authenticate"
+    /// response header field matching the authentication scheme used
+    /// by the client.
+    InvalidClient,
+    /// The provided authorization grant (e.g., authorization
+    /// code, resource owner credentials) or refresh token is
+    /// invalid, expired, revoked, does not match the redirection
+    /// URI used in the authorization request, or was issued to
+    /// another client.
+    InvalidGrant,
+    /// The authenticated client is not authorized to use this
+    /// authorization grant type.
+    UnauthorizedClient,
+    /// The authorization grant type is not supported by the
+    /// authorization server.
+    UnsupportedGrantType,
+    /// The requested scope is invalid, unknown, malformed, or
+    /// exceeds the scope granted by the resource owner.
+    InvalidScope
+}
+// TODO consider relying on serde_variant for these conversions
+impl AsRef<str> for ErrorKind {
+    fn as_ref(&self) -> &str {
+        match self {
+            ErrorKind::InvalidRequest => "invalid_request",
+            ErrorKind::InvalidClient => "invalid_client",
+            ErrorKind::InvalidGrant => "invalid_grant",
+            ErrorKind::UnauthorizedClient => "unauthorized_client",
+            ErrorKind::UnsupportedGrantType => "unsupported_grant_type",
+            ErrorKind::InvalidScope => "invalid_scope",
+        }
+    }
+}
+impl std::fmt::Display for ErrorKind {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.as_ref())
+    }
+}
+
+/// 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<String>,
+    /// An URL to documentation describing what went wrong and how to
+    /// fix it.
+    pub error_uri: Option<url::Url>
+}
+
+impl From<ErrorKind> for Error {
+    fn from(kind: ErrorKind) -> Error {
+        Error {
+            kind, msg: None, error_uri: None
+        }
+    }
+}
+
+impl std::error::Error for self::Error {}
+
+impl std::fmt::Display for self::Error {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "IndieAuth error ({})", self.kind)?;
+        if let Some(msg) = self.msg.as_deref() {
+            write!(f, ": {}", msg)?;
+        }
+        if let Some(error_uri) = &self.error_uri {
+            write!(f, " (see `{}` for more info)", error_uri)?;
+        }
+
+        Ok(())
+    }
+}
+
+#[cfg(feature = "axum")]
+impl axum_core::response::IntoResponse for self::Error {
+    fn into_response(self) -> axum_core::response::Response {
+        use http::StatusCode;
+
+        (StatusCode::BAD_REQUEST,
+         [("Content-Type", "application/json")],
+         serde_json::to_vec(&self).unwrap())
+            .into_response()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_serialize_deserialize_grant_request() {
+        let authorization_code: GrantRequest = GrantRequest::AuthorizationCode {
+            client_id: "https://kittybox.fireburn.ru/".parse().unwrap(),
+            redirect_uri: "https://kittybox.fireburn.ru/.kittybox/login/redirect".parse().unwrap(),
+            code_verifier: PKCEVerifier("helloworld".to_string()),
+            code: "hithere".to_owned()
+        };
+        let serialized = serde_urlencoded::to_string(&[
+            ("grant_type", "authorization_code"),
+            ("code", "hithere"),
+            ("client_id", "https://kittybox.fireburn.ru/"),
+            ("redirect_uri", "https://kittybox.fireburn.ru/.kittybox/login/redirect"),
+            ("code_verifier", "helloworld"),
+        ]).unwrap();
+
+        let deserialized = serde_urlencoded::from_str(&serialized).unwrap();
+
+        assert_eq!(authorization_code, deserialized);
+
+        assert_eq!(
+            serialized,
+            serde_urlencoded::to_string(authorization_code).unwrap()
+        )
+    }
+}
diff --git a/indieauth/src/pkce.rs b/indieauth/src/pkce.rs
new file mode 100644
index 0000000..bf8d1a0
--- /dev/null
+++ b/indieauth/src/pkce.rs
@@ -0,0 +1,132 @@
+use serde::{Serialize, Deserialize};
+use rand::{Rng, distributions::Alphanumeric};
+use sha2::{Sha256, Digest};
+use data_encoding::BASE64URL;
+
+/// 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.
+    #[serde(rename = "snake_case")]
+    Plain
+}
+// manual impl until Rust 1.62 hits nixos-unstable
+impl Default for PKCEMethod {
+    fn default() -> Self { PKCEMethod::S256 }
+}
+impl PKCEMethod {
+    /// Return a string representing a PKCE method as it would be serialized.
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            PKCEMethod::S256 => "S256",
+            PKCEMethod::Plain => "plain"
+        }
+    }
+}
+/// 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);
+
+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 {
+    /// 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)
+            .collect::<Vec<u8>>();
+        Self(String::from_utf8(bytes).unwrap())
+    }
+}
+
+/// 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,
+    #[serde(rename = "code_challenge_method")]
+    method: PKCEMethod
+}
+
+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 {
+                PKCEMethod::S256 => {
+                    let mut hasher = Sha256::new();
+                    hasher.update(code_verifier.as_ref());
+                    let mut challenge = BASE64URL.encode(&hasher.finalize());
+                    challenge.retain(|c| c != '=');
+
+                    challenge
+                },
+                PKCEMethod::Plain => code_verifier.to_string(),
+            },
+            method
+        }
+    }
+
+    /// 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, 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
+    }
+
+    /// Return a reference to the code challenge string.
+    pub fn as_str(&self) -> &str {
+        self.code_challenge.as_str()
+    }
+
+    /// Return the method used to create this challenge.
+    pub fn method(&self) -> PKCEMethod {
+        self.method
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{PKCEMethod, PKCEVerifier, PKCEChallenge};
+
+    #[test]
+    /// A snapshot test generated using [Aaron Parecki's PKCE
+    /// tools](https://example-app.com/pkce) that checks for a
+    /// conforming challenge.
+    fn test_pkce_challenge_verification() {
+        let verifier = PKCEVerifier("ec03310e4e90f7bc988af05384060c3c1afeae4bb4d0f648c5c06b63".to_owned());
+
+        let challenge = PKCEChallenge::new(&verifier, PKCEMethod::S256);
+
+        assert_eq!(challenge.as_str(), "aB8OG20Rh8UoQ9gFhI0YvPkx4dDW2MBspBKGXL6j6Wg");
+    }
+}
diff --git a/indieauth/src/scopes.rs b/indieauth/src/scopes.rs
new file mode 100644
index 0000000..d74878e
--- /dev/null
+++ b/indieauth/src/scopes.rs
@@ -0,0 +1,208 @@
+use std::str::FromStr;
+
+use serde::{
+    Serialize, Serializer,
+    Deserialize,
+    de::{
+        Deserializer, Visitor,
+        Error as DeserializeError
+    }
+};
+
+/// Various scopes that can be requested through IndieAuth.
+#[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 {
+    /// Create a custom scope from a string slice.
+    pub fn custom(scope: &str) -> Scope {
+        Scope::Custom(scope.to_string())
+    }
+}
+
+// TODO consider relying on serde_variant 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)
+        }
+    }
+}
+impl FromStr for Scope {
+    type Err = std::convert::Infallible;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(s.into())
+    }
+}
+
+/// 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<Scope>);
+impl Scopes {
+    /// Create a list of scopes from a vector of scopes.
+    pub fn new(scopes: Vec<Scope>) -> 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()
+    }
+}
+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 FromStr for Scopes {
+    type Err = std::convert::Infallible;
+
+    fn from_str(value: &str) -> Result<Self, Self::Err> {
+        Ok(Self(value.split_ascii_whitespace()
+                .map(Scope::from)
+                .collect::<Vec<Scope>>()))
+    }
+}
+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::from_str(value).unwrap())
+    }
+}
+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))        
+    }
+
+    #[test]
+    fn test_scope_has_all() {
+        let scopes = Scopes(vec![
+            Scope::Create, Scope::Update, Scope::custom("draft")
+        ]);
+
+        assert!(scopes.has_all(&[Scope::Create, Scope::custom("draft")]));
+        
+        assert!(!scopes.has_all(&[Scope::Read, Scope::custom("kittybox_internal_access")]));
+    }
+
+}