diff options
Diffstat (limited to 'kittybox-rs/indieauth/src/lib.rs')
-rw-r--r-- | kittybox-rs/indieauth/src/lib.rs | 296 |
1 files changed, 290 insertions, 6 deletions
diff --git a/kittybox-rs/indieauth/src/lib.rs b/kittybox-rs/indieauth/src/lib.rs index 5896ebb..826da4e 100644 --- a/kittybox-rs/indieauth/src/lib.rs +++ b/kittybox-rs/indieauth/src/lib.rs @@ -1,3 +1,23 @@ +#![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; @@ -6,39 +26,96 @@ 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 } +/// 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 @@ -52,35 +129,92 @@ pub struct Metadata { 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. + /// 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. pub name: String, + /// User's profile page. Fetching it may reveal an `h-card`. pub url: Url, + /// User's profile picture suitable to represent them. pub photo: 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> } @@ -97,9 +231,15 @@ impl axum_core::response::IntoResponse for Profile { } } +/// 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. fn new() -> Self { use rand::{Rng, distributions::Alphanumeric}; let bytes = rand::thread_rng() @@ -110,60 +250,165 @@ impl State { } } +/// 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, +/// 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(); +/// +/// authorization_endpoint.set_query(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> } } +/// 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, + /// 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> } @@ -189,29 +434,48 @@ impl axum_core::response::IntoResponse for GrantResponse { /// 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}; @@ -223,12 +487,13 @@ impl TokenData { .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) @@ -240,6 +505,11 @@ impl TokenData { // 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, @@ -250,17 +520,20 @@ pub struct TokenIntrospectionResponse { // 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 @@ -296,9 +569,11 @@ impl axum_core::response::IntoResponse for TokenIntrospectionResponse { } } - +/// 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 } @@ -307,11 +582,15 @@ pub struct TokenRevocationRequest { #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ResourceErrorKind { - InvalidRequest, + /// 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 { @@ -367,13 +646,18 @@ impl std::fmt::Display for ErrorKind { } } - +/// 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> } |