diff options
Diffstat (limited to 'kittybox-rs/indieauth')
-rw-r--r-- | kittybox-rs/indieauth/Cargo.toml | 31 | ||||
-rw-r--r-- | kittybox-rs/indieauth/src/lib.rs | 773 | ||||
-rw-r--r-- | kittybox-rs/indieauth/src/pkce.rs | 132 | ||||
-rw-r--r-- | kittybox-rs/indieauth/src/scopes.rs | 208 |
4 files changed, 0 insertions, 1144 deletions
diff --git a/kittybox-rs/indieauth/Cargo.toml b/kittybox-rs/indieauth/Cargo.toml deleted file mode 100644 index d6bc1fe..0000000 --- a/kittybox-rs/indieauth/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[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/kittybox-rs/indieauth/src/lib.rs b/kittybox-rs/indieauth/src/lib.rs deleted file mode 100644 index a60cc42..0000000 --- a/kittybox-rs/indieauth/src/lib.rs +++ /dev/null @@ -1,773 +0,0 @@ -#![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/kittybox-rs/indieauth/src/pkce.rs b/kittybox-rs/indieauth/src/pkce.rs deleted file mode 100644 index bf8d1a0..0000000 --- a/kittybox-rs/indieauth/src/pkce.rs +++ /dev/null @@ -1,132 +0,0 @@ -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/kittybox-rs/indieauth/src/scopes.rs b/kittybox-rs/indieauth/src/scopes.rs deleted file mode 100644 index d74878e..0000000 --- a/kittybox-rs/indieauth/src/scopes.rs +++ /dev/null @@ -1,208 +0,0 @@ -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")])); - } - -} |