#![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()
)
}
}