diff options
Diffstat (limited to 'indieauth')
-rw-r--r-- | indieauth/Cargo.toml | 31 | ||||
-rw-r--r-- | indieauth/src/lib.rs | 773 | ||||
-rw-r--r-- | indieauth/src/pkce.rs | 132 | ||||
-rw-r--r-- | indieauth/src/scopes.rs | 208 |
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")])); + } + +} |