diff options
Diffstat (limited to 'indieauth/src/lib.rs')
-rw-r--r-- | indieauth/src/lib.rs | 291 |
1 files changed, 183 insertions, 108 deletions
diff --git a/indieauth/src/lib.rs b/indieauth/src/lib.rs index b3ec098..b10fd0e 100644 --- a/indieauth/src/lib.rs +++ b/indieauth/src/lib.rs @@ -20,13 +20,13 @@ //! [`axum`]: https://github.com/tokio-rs/axum use std::borrow::Cow; -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; use url::Url; mod scopes; pub use self::scopes::{Scope, Scopes}; mod pkce; -pub use self::pkce::{PKCEMethod, PKCEVerifier, PKCEChallenge}; +pub use self::pkce::{PKCEChallenge, PKCEMethod, PKCEVerifier}; // Re-export rand crate just to be sure. pub use rand; @@ -34,23 +34,24 @@ pub use rand; /// Authentication methods supported by the introspection endpoint. /// Note that authentication at the introspection endpoint is /// mandatory. -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[non_exhaustive] pub enum IntrospectionEndpointAuthMethod { /// `Authorization` header with a `Bearer` token. + #[serde(rename_all = "PascalCase")] Bearer, /// A token passed as part of a POST request. - #[serde(rename = "snake_case")] + #[serde(rename_all = "snake_case")] ClientSecretPost, /// Username and password passed using HTTP Basic authentication. - #[serde(rename = "snake_case")] + #[serde(rename_all = "snake_case")] ClientSecretBasic, /// TLS client auth with a certificate signed by a valid CA. - #[serde(rename = "snake_case")] + #[serde(rename_all = "snake_case")] TlsClientAuth, /// TLS client auth with a self-signed certificate. - #[serde(rename = "snake_case")] - SelfSignedTlsClientAuth + #[serde(rename_all = "snake_case")] + SelfSignedTlsClientAuth, } /// Authentication methods supported by the revocation endpoint. @@ -60,17 +61,17 @@ pub enum IntrospectionEndpointAuthMethod { /// 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)] +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] #[non_exhaustive] pub enum RevocationEndpointAuthMethod { /// No authentication is required to access an endpoint declaring /// this value. - None + None, } /// The response types supported by the authorization endpoint. -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ResponseType { /// An authorization code will be issued if this response type is @@ -82,7 +83,7 @@ pub enum ResponseType { /// This response type requires a valid access token. /// /// [AutoAuth spec]: https://github.com/sknebel/AutoAuth/blob/master/AutoAuth.md#allowing-external-clients-to-obtain-tokens - ExternalToken + ExternalToken, } // TODO serde_variant impl ResponseType { @@ -100,7 +101,7 @@ impl ResponseType { /// 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)] +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum GrantType { /// The authorization code grant, allowing to exchange an @@ -110,7 +111,7 @@ pub enum GrantType { /// 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 + RefreshToken, } /// OAuth 2.0 Authorization Server Metadata in application to the IndieAuth protocol. @@ -222,7 +223,7 @@ pub struct Metadata { /// registration. #[serde(skip_serializing_if = "ref_identity")] #[serde(default = "Default::default")] - pub client_id_metadata_document_supported: bool + pub client_id_metadata_document_supported: bool, } impl std::fmt::Debug for Metadata { @@ -232,31 +233,59 @@ impl std::fmt::Debug for Metadata { .field("authorization_endpoint", &self.issuer.as_str()) .field("token_endpoint", &self.issuer.as_str()) .field("introspection_endpoint", &self.issuer.as_str()) - .field("introspection_endpoint_auth_methods_supported", &self.introspection_endpoint_auth_methods_supported) - .field("revocation_endpoint", &self.revocation_endpoint.as_ref().map(Url::as_str)) - .field("revocation_endpoint_auth_methods_supported", &self.revocation_endpoint_auth_methods_supported) + .field( + "introspection_endpoint_auth_methods_supported", + &self.introspection_endpoint_auth_methods_supported, + ) + .field( + "revocation_endpoint", + &self.revocation_endpoint.as_ref().map(Url::as_str), + ) + .field( + "revocation_endpoint_auth_methods_supported", + &self.revocation_endpoint_auth_methods_supported, + ) .field("scopes_supported", &self.scopes_supported) .field("response_types_supported", &self.response_types_supported) .field("grant_types_supported", &self.grant_types_supported) - .field("service_documentation", &self.service_documentation.as_ref().map(Url::as_str)) - .field("code_challenge_methods_supported", &self.code_challenge_methods_supported) - .field("authorization_response_iss_parameter_supported", &self.authorization_response_iss_parameter_supported) - .field("userinfo_endpoint", &self.userinfo_endpoint.as_ref().map(Url::as_str)) - .field("client_id_metadata_document_supported", &self.client_id_metadata_document_supported) + .field( + "service_documentation", + &self.service_documentation.as_ref().map(Url::as_str), + ) + .field( + "code_challenge_methods_supported", + &self.code_challenge_methods_supported, + ) + .field( + "authorization_response_iss_parameter_supported", + &self.authorization_response_iss_parameter_supported, + ) + .field( + "userinfo_endpoint", + &self.userinfo_endpoint.as_ref().map(Url::as_str), + ) + .field( + "client_id_metadata_document_supported", + &self.client_id_metadata_document_supported, + ) .finish() } } -fn ref_identity(v: &bool) -> bool { *v } +fn ref_identity(v: &bool) -> bool { + *v +} #[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()) + ( + StatusCode::OK, + [("Content-Type", "application/json")], + serde_json::to_vec(&self).unwrap(), + ) .into_response() } } @@ -308,24 +337,37 @@ pub struct ClientMetadata { pub software_version: Option<Cow<'static, str>>, /// URI for the homepage of this client's owners #[serde(skip_serializing_if = "Option::is_none")] - pub homepage_uri: Option<Url> + pub homepage_uri: Option<Url>, +} + +/// Error that occurs when creating [`ClientMetadata`] with mismatched `client_id` and `client_uri`. +#[derive(Debug)] +pub struct ClientIdMismatch; + +impl std::error::Error for ClientIdMismatch {} +impl std::fmt::Display for ClientIdMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "client_id must be a prefix of client_uri") + } } impl ClientMetadata { - /// Create a new [`ClientMetadata`] with all the optional fields - /// omitted. + /// Create a new [`ClientMetadata`] with all the optional fields omitted. /// /// # Errors /// - /// Returns `()` if the `client_uri` is not a prefix of - /// `client_id` as required by the IndieAuth spec. - pub fn new(client_id: url::Url, client_uri: url::Url) -> Result<Self, ()> { - if client_id.as_str().as_bytes()[..client_uri.as_str().len()] != *client_uri.as_str().as_bytes() { - return Err(()); + /// Returns `()` if the `client_uri` is not a prefix of `client_id` as required by the IndieAuth + /// spec. + pub fn new(client_id: url::Url, client_uri: url::Url) -> Result<Self, ClientIdMismatch> { + if client_id.as_str().as_bytes()[..client_uri.as_str().len()] + != *client_uri.as_str().as_bytes() + { + return Err(ClientIdMismatch); } Ok(Self { - client_id, client_uri, + client_id, + client_uri, client_name: None, logo_uri: None, redirect_uris: None, @@ -355,14 +397,15 @@ impl axum_core::response::IntoResponse for ClientMetadata { fn into_response(self) -> axum_core::response::Response { use http::StatusCode; - (StatusCode::OK, - [("Content-Type", "application/json")], - serde_json::to_vec(&self).unwrap()) + ( + 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)] @@ -379,7 +422,7 @@ pub struct Profile { /// 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> + pub email: Option<String>, } #[cfg(feature = "axum")] @@ -387,9 +430,11 @@ 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()) + ( + StatusCode::OK, + [("Content-Type", "application/json")], + serde_json::to_vec(&self).unwrap(), + ) .into_response() } } @@ -414,13 +459,13 @@ impl State { /// Generate a random state string of 128 bytes in length, using /// the provided random number generator. pub fn from_rng(rng: &mut (impl rand::CryptoRng + rand::Rng)) -> Self { - use rand::{Rng, distributions::Alphanumeric}; + use rand::{distributions::Alphanumeric, Rng}; - let bytes = rng.sample_iter(&Alphanumeric) + let bytes = rng + .sample_iter(&Alphanumeric) .take(128) .collect::<Vec<u8>>(); Self(String::from_utf8(bytes).unwrap()) - } } impl AsRef<str> for State { @@ -503,21 +548,23 @@ impl AuthorizationRequest { ("response_type", Cow::Borrowed(self.response_type.as_str())), ("client_id", Cow::Borrowed(self.client_id.as_str())), ("redirect_uri", Cow::Borrowed(self.redirect_uri.as_str())), - ("code_challenge", Cow::Borrowed(self.code_challenge.as_str())), - ("code_challenge_method", Cow::Borrowed(self.code_challenge.method().as_str())), - ("state", Cow::Borrowed(self.state.as_ref())) + ( + "code_challenge", + Cow::Borrowed(self.code_challenge.as_str()), + ), + ( + "code_challenge_method", + Cow::Borrowed(self.code_challenge.method().as_str()), + ), + ("state", Cow::Borrowed(self.state.as_ref())), ]; if let Some(ref scope) = self.scope { - v.push( - ("scope", Cow::Owned(scope.to_string())) - ); + v.push(("scope", Cow::Owned(scope.to_string()))); } if let Some(ref me) = self.me { - v.push( - ("me", Cow::Borrowed(me.as_str())) - ); + v.push(("me", Cow::Borrowed(me.as_str()))); } v @@ -550,17 +597,22 @@ pub struct AutoAuthRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AutoAuthCallbackData { state: State, - callback_url: Url + callback_url: Url, } #[inline(always)] -fn deserialize_secs<'de, D: serde::de::Deserializer<'de>>(d: D) -> Result<std::time::Duration, D::Error> { +fn deserialize_secs<'de, D: serde::de::Deserializer<'de>>( + d: D, +) -> Result<std::time::Duration, D::Error> { use serde::Deserialize; Ok(std::time::Duration::from_secs(u64::deserialize(d)?)) } #[inline(always)] -fn serialize_secs<S: serde::ser::Serializer>(d: &std::time::Duration, s: S) -> Result<S::Ok, S::Error> { +fn serialize_secs<S: serde::ser::Serializer>( + d: &std::time::Duration, + s: S, +) -> Result<S::Ok, S::Error> { s.serialize_u64(std::time::Duration::as_secs(d)) } @@ -570,7 +622,7 @@ pub struct AutoAuthPollingResponse { request_id: State, #[serde(serialize_with = "serialize_secs")] #[serde(deserialize_with = "deserialize_secs")] - interval: std::time::Duration + interval: std::time::Duration, } /// The authorization response that must be appended to the @@ -602,10 +654,9 @@ pub struct AuthorizationResponse { /// authorization server. /// /// [oauth2-iss]: https://www.ietf.org/archive/id/draft-ietf-oauth-iss-auth-resp-02.html - pub iss: Url + pub iss: Url, } - /// A special grant request that is used in the AutoAuth ceremony. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AutoAuthCodeGrant { @@ -628,7 +679,7 @@ pub struct AutoAuthCodeGrant { callback_url: Url, /// The user's URL. Will be used to confirm the authorization /// endpoint's authority. - me: Url + me: Url, } /// A grant request that continues the IndieAuth ceremony. @@ -647,7 +698,7 @@ pub enum GrantRequest { redirect_uri: Url, /// The PKCE code verifier that was used to create the code /// challenge. - code_verifier: PKCEVerifier + code_verifier: PKCEVerifier, }, /// Use a refresh token to get a fresh access token and a new /// matching refresh token. @@ -662,8 +713,8 @@ pub enum GrantRequest { /// /// This cannot be used to gain new scopes -- you need to /// start over if you need new scopes from the user. - scope: Option<Scopes> - } + scope: Option<Scopes>, + }, } /// Token type, as described in [RFC6749][]. @@ -677,7 +728,7 @@ pub enum TokenType { /// IndieAuth uses. /// /// [RFC6750]: https://www.rfc-editor.org/rfc/rfc6750 - Bearer + Bearer, } /// The response to a successful [`GrantRequest`]. @@ -714,14 +765,14 @@ pub enum GrantResponse { profile: Option<Profile>, /// The refresh token, if it was issued. #[serde(skip_serializing_if = "Option::is_none")] - refresh_token: Option<String> + 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(ProfileUrl) + ProfileUrl(ProfileUrl), } /// The contents of a profile URL response. @@ -731,7 +782,7 @@ pub struct ProfileUrl { pub me: Url, /// The user's profile information, if it was requested. #[serde(skip_serializing_if = "Option::is_none")] - pub profile: Option<Profile> + pub profile: Option<Profile>, } #[cfg(feature = "axum")] @@ -739,12 +790,15 @@ 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()) + ( + StatusCode::OK, + [ + ("Content-Type", "application/json"), + ("Cache-Control", "no-store"), + ("Pragma", "no-cache"), + ], + serde_json::to_vec(&self).unwrap(), + ) .into_response() } } @@ -758,7 +812,7 @@ impl axum_core::response::IntoResponse for GrantResponse { pub enum RequestMaybeAuthorizationEndpoint { Authorization(AuthorizationRequest), Grant(GrantRequest), - AutoAuth(AutoAuthCodeGrant) + AutoAuth(AutoAuthCodeGrant), } /// A token introspection request that can be handled by the token @@ -770,7 +824,7 @@ pub enum RequestMaybeAuthorizationEndpoint { #[derive(Debug, Serialize, Deserialize)] pub struct TokenIntrospectionRequest { /// The token for which data was requested. - pub token: String + pub token: String, } /// Data for a token that will be returned by the introspection @@ -792,7 +846,7 @@ pub struct TokenData { /// 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> + pub iat: Option<u64>, } impl TokenData { @@ -801,24 +855,25 @@ impl TokenData { use std::time::{Duration, SystemTime, UNIX_EPOCH}; self.exp - .map(|exp| SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or(Duration::ZERO) - .as_secs() >= 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) - }) + 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) - }) + self.iat + .map(|time| std::time::UNIX_EPOCH + std::time::Duration::from_secs(time)) } /// Check if a certain scope is allowed for this token. @@ -841,18 +896,24 @@ pub struct TokenIntrospectionResponse { active: bool, #[serde(flatten)] #[serde(skip_serializing_if = "Option::is_none")] - data: Option<TokenData> + 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 } + 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) } + Self { + active: true, + data: Some(data), + } } /// Check if the endpoint reports this token as valid. pub fn is_active(&self) -> bool { @@ -862,7 +923,7 @@ impl TokenIntrospectionResponse { /// Get data contained in the response, if the token is valid. pub fn data(&self) -> Option<&TokenData> { if !self.active { - return None + return None; } self.data.as_ref() } @@ -874,7 +935,10 @@ impl Default for TokenIntrospectionResponse { } impl From<Option<TokenData>> for TokenIntrospectionResponse { fn from(data: Option<TokenData>) -> Self { - Self { active: data.is_some(), data } + Self { + active: data.is_some(), + data, + } } } impl From<TokenIntrospectionResponse> for Option<TokenData> { @@ -888,9 +952,11 @@ 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()) + ( + StatusCode::OK, + [("Content-Type", "application/json")], + serde_json::to_vec(&self).unwrap(), + ) .into_response() } } @@ -900,7 +966,7 @@ impl axum_core::response::IntoResponse for TokenIntrospectionResponse { #[derive(Debug, Serialize, Deserialize)] pub struct TokenRevocationRequest { /// The token that needs to be revoked in case it is valid. - pub token: String + pub token: String, } /// Types of errors that a resource server (IndieAuth consumer) can @@ -961,7 +1027,6 @@ pub enum ErrorKind { /// AutoAuth/OAuth2 Device Flow: Access was denied by the /// authorization endpoint. AccessDenied, - } // TODO consider relying on serde_variant for these conversions impl AsRef<str> for ErrorKind { @@ -997,13 +1062,15 @@ pub struct Error { pub msg: Option<String>, /// An URL to documentation describing what went wrong and how to /// fix it. - pub error_uri: Option<url::Url> + pub error_uri: Option<url::Url>, } impl From<ErrorKind> for Error { fn from(kind: ErrorKind) -> Error { Error { - kind, msg: None, error_uri: None + kind, + msg: None, + error_uri: None, } } } @@ -1029,9 +1096,11 @@ 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()) + ( + StatusCode::BAD_REQUEST, + [("Content-Type", "application/json")], + serde_json::to_vec(&self).unwrap(), + ) .into_response() } } @@ -1044,17 +1113,23 @@ mod tests { 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(), + redirect_uri: "https://kittybox.fireburn.ru/.kittybox/login/redirect" + .parse() + .unwrap(), code_verifier: PKCEVerifier("helloworld".to_string()), - code: "hithere".to_owned() + 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"), + ( + "redirect_uri", + "https://kittybox.fireburn.ru/.kittybox/login/redirect", + ), ("code_verifier", "helloworld"), - ]).unwrap(); + ]) + .unwrap(); let deserialized = serde_urlencoded::from_str(&serialized).unwrap(); |