about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--kittybox-rs/indieauth/src/lib.rs116
-rw-r--r--kittybox-rs/indieauth/src/scopes.rs3
-rw-r--r--kittybox-rs/src/indieauth/mod.rs74
3 files changed, 157 insertions, 36 deletions
diff --git a/kittybox-rs/indieauth/src/lib.rs b/kittybox-rs/indieauth/src/lib.rs
index eca1102..b461fea 100644
--- a/kittybox-rs/indieauth/src/lib.rs
+++ b/kittybox-rs/indieauth/src/lib.rs
@@ -156,6 +156,9 @@ pub enum GrantResponse {
     }
 }
 
+/// 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)]
 pub enum RequestMaybeAuthorizationEndpoint {
@@ -257,37 +260,108 @@ pub struct TokenRevocationRequest {
     pub token: String
 }
 
-// TODO rework in accordance with https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
-// turns out I got some values wrong
+/// 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")]
-#[serde(tag = "error")]
-pub enum IndieAuthError {
+pub enum ResourceErrorKind {
     InvalidRequest,
     InvalidToken,
     InsufficientScope,
 }
 
+#[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())
+    }
+}
+
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Error {
+    #[serde(rename = "error")]
+    pub kind: ErrorKind,
+    #[serde(rename = "error_description")]
+    pub msg: Option<String>,
+    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(test)]
 mod tests {
     use super::*;
-    use serde_json::json;
-
-    #[test]
-    fn test_serialize_indieauth_error() {
-        assert_eq!(
-            serde_json::to_value(IndieAuthError::InvalidRequest).unwrap(),
-            json!({"error": "invalid_request"})
-        );
-        assert_eq!(
-            serde_json::to_value(IndieAuthError::InvalidToken).unwrap(),
-            json!({"error": "invalid_token"})
-        );
-        assert_eq!(
-            serde_json::to_value(IndieAuthError::InsufficientScope).unwrap(),
-            json!({"error": "insufficient_scope"})
-        );
-    }
 
     #[test]
     fn test_serialize_deserialize_grant_request() {
diff --git a/kittybox-rs/indieauth/src/scopes.rs b/kittybox-rs/indieauth/src/scopes.rs
index e803dca..18ebfbd 100644
--- a/kittybox-rs/indieauth/src/scopes.rs
+++ b/kittybox-rs/indieauth/src/scopes.rs
@@ -42,7 +42,8 @@ impl Scope {
         Scope::Custom(scope.to_string())
     }
 }
-// TODO consider relying on serde for these conversions
+
+// TODO consider relying on serde_variant for these conversions
 impl AsRef<str> for Scope {
     fn as_ref(&self) -> &str {
         use Scope::*;
diff --git a/kittybox-rs/src/indieauth/mod.rs b/kittybox-rs/src/indieauth/mod.rs
index a985d16..8100b2a 100644
--- a/kittybox-rs/src/indieauth/mod.rs
+++ b/kittybox-rs/src/indieauth/mod.rs
@@ -6,11 +6,11 @@ use axum::{
 };
 use kittybox_indieauth::{
     Metadata, IntrospectionEndpointAuthMethod, RevocationEndpointAuthMethod,
-    Scope, Scopes, PKCEMethod,
+    Scope, Scopes, PKCEMethod, Error, ErrorKind,
     ResponseType, RequestMaybeAuthorizationEndpoint,
     AuthorizationRequest, AuthorizationResponse,
     GrantType, GrantRequest, GrantResponse, Profile,
-    TokenIntrospectionRequest, TokenIntrospectionResponse, TokenRevocationRequest, IndieAuthError, TokenData
+    TokenIntrospectionRequest, TokenIntrospectionResponse, TokenRevocationRequest, TokenData
 };
 
 pub mod backend;
@@ -109,14 +109,23 @@ async fn authorization_endpoint_post<A: AuthBackend>(
             GrantRequest::AuthorizationCode { code, client_id, redirect_uri, code_verifier } => {
                 let request: AuthorizationRequest = match backend.get_code(&code).await {
                     Ok(Some(request)) => request,
-                    Ok(None) => return Json(IndieAuthError::InvalidRequest).into_response(),
+                    Ok(None) => return Json(Error {
+                        kind: ErrorKind::InvalidGrant,
+                        msg: Some("The provided authorization code is invalid.".to_string()),
+                        error_uri: None
+                    }).into_response(),
                     Err(err) => {
                         tracing::error!("Error retrieving auth request: {}", err);
                         return StatusCode::INTERNAL_SERVER_ERROR.into_response();
                     }
                 };
                 if !request.code_challenge.verify(code_verifier) {
-                    return Json(IndieAuthError::InvalidRequest).into_response()
+                    return Json(Error {
+                        kind: ErrorKind::InvalidGrant,
+                        msg: Some("The PKCE challenge failed.".to_string()),
+                        // are RFCs considered human-readable? 😝
+                        error_uri: "https://datatracker.ietf.org/doc/html/rfc7636#section-4.6".parse().ok()
+                    }).into_response()
                 }
                 let profile = if request.scope
                     .map(|s| s.has(&Scope::Profile))
@@ -130,7 +139,11 @@ async fn authorization_endpoint_post<A: AuthBackend>(
 
                 Json(GrantResponse::ProfileUrl { me, profile }).into_response()
             },
-            _ => Json(IndieAuthError::InvalidRequest).into_response()
+            _ => Json(Error {
+                kind: ErrorKind::InvalidGrant,
+                msg: Some("The provided grant_type is unusable on this endpoint.".to_string()),
+                error_uri: "https://indieauth.spec.indieweb.org/#redeeming-the-authorization-code".parse().ok()
+            }).into_response()
         }
     }
 }
@@ -182,7 +195,11 @@ async fn token_endpoint_post<A: AuthBackend>(
             // TODO verify PKCE challenge using grant.code_verifier
             let request: AuthorizationRequest = match backend.get_code(&code).await {
                 Ok(Some(request)) => request,
-                Ok(None) => return Json(IndieAuthError::InvalidRequest).into_response(),
+                Ok(None) => return Json(Error {
+                    kind: ErrorKind::InvalidGrant,
+                    msg: Some("The provided authorization code is invalid.".to_string()),
+                    error_uri: None
+                }).into_response(),
                 Err(err) => {
                     tracing::error!("Error retrieving auth request: {}", err);
                     return StatusCode::INTERNAL_SERVER_ERROR.into_response();
@@ -192,9 +209,21 @@ async fn token_endpoint_post<A: AuthBackend>(
             let me: url::Url = format!("https://{}/", host).parse().unwrap();
 
             let scope = if let Some(scope) = request.scope { scope } else {
-                return Json(IndieAuthError::InvalidRequest).into_response();
+                return Json(Error {
+                    kind: ErrorKind::InvalidScope,
+                    msg: Some("Tokens cannot be issued if no scopes are requested.".to_string()),
+                    error_uri: "https://indieauth.spec.indieweb.org/#access-token-response".parse().ok()
+                }).into_response();
             };
 
+            if !request.code_challenge.verify(code_verifier) {
+                return Json(Error {
+                    kind: ErrorKind::InvalidGrant,
+                    msg: Some("The PKCE challenge failed.".to_string()),
+                    error_uri: "https://datatracker.ietf.org/doc/html/rfc7636#section-4.6".parse().ok()
+                }).into_response();
+            }
+
             let profile = if scope.has(&Scope::Profile) {
                 Some(todo!())
             } else {
@@ -232,7 +261,11 @@ async fn token_endpoint_post<A: AuthBackend>(
         GrantRequest::RefreshToken { refresh_token, client_id, scope } => {
             let data = match backend.get_refresh_token(&refresh_token).await {
                 Ok(Some(token)) => token,
-                Ok(None) => return Json(IndieAuthError::InvalidToken).into_response(),
+                Ok(None) => return Json(Error {
+                    kind: ErrorKind::InvalidGrant,
+                    msg: Some("This refresh token is not valid.".to_string()),
+                    error_uri: None
+                }).into_response(),
                 Err(err) => {
                     tracing::error!("Error retrieving refresh token: {}", err);
                     return StatusCode::INTERNAL_SERVER_ERROR.into_response()
@@ -240,15 +273,28 @@ async fn token_endpoint_post<A: AuthBackend>(
             };
 
             if data.client_id != client_id {
-                return Json(IndieAuthError::InvalidRequest).into_response();
+                return Json(Error {
+                    kind: ErrorKind::InvalidGrant,
+                    msg: Some("This refresh token is not yours.".to_string()),
+                    error_uri: None
+                }).into_response();
             }
 
-            let scope = if let Some(scope) = scope { scope } else {
-                return Json(IndieAuthError::InvalidRequest).into_response();
+            let scope = if let Some(scope) = scope {
+                if !data.scope.has_all(scope.as_ref()) {
+                    return Json(Error {
+                        kind: ErrorKind::InvalidScope,
+                        msg: Some("You can't request additional scopes through the refresh token grant.".to_string()),
+                        error_uri: None
+                    }).into_response();
+                }
+
+                scope
+            } else {
+                // Note: check skipped because of redundancy (comparing a scope list with itself)
+                data.scope
             };
-            if !data.scope.has_all(scope.as_ref()) {
-                return Json(IndieAuthError::InsufficientScope).into_response();
-            }
+
 
             let profile = if scope.has(&Scope::Profile) {
                 Some(todo!())