#![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>, /// The Revocation Endpoint #[serde(skip_serializing_if = "Option::is_none")] pub revocation_endpoint: Option, /// 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>, /// 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>, /// 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>, /// 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>, /// 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, /// 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, /// 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, /// The User Info Endpoint #[serde(skip_serializing_if = "Option::is_none")] pub userinfo_endpoint: Option } #[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, /// User's profile page. Fetching it may reveal an `h-card`. #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, /// User's profile picture suitable to represent them. #[serde(skip_serializing_if = "Option::is_none")] pub photo: Option, /// 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 } #[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::>(); Self(String::from_utf8(bytes).unwrap()) } } impl AsRef 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, /// 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 } /// 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 } } /// 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, /// The user's profile information, if it was requested. #[serde(skip_serializing_if = "Option::is_none")] profile: Option, /// 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, /// The refresh token, if it was issued. #[serde(skip_serializing_if = "Option::is_none")] refresh_token: Option }, /// 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 } } #[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, /// The issue date, represented in the same format as the /// [`exp`][TokenData::exp] field. #[serde(skip_serializing_if = "Option::is_none")] pub iat: Option } 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 { 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 { self.iat.map(|time| { std::time::UNIX_EPOCH + std::time::Duration::from_secs(time) }) } } // I don't like this type, because it could've been represented // internally by Option. 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` for ergonomics. #[derive(Debug, Serialize, Deserialize)] pub struct TokenIntrospectionResponse { active: bool, #[serde(flatten)] #[serde(skip_serializing_if = "Option::is_none")] data: Option } // 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> for TokenIntrospectionResponse { fn from(data: Option) -> Self { Self { active: data.is_some(), data } } } impl From for Option { fn from(response: TokenIntrospectionResponse) -> Option { 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 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, /// An URL to documentation describing what went wrong and how to /// fix it. pub error_uri: Option } impl From 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() ) } }