diff options
-rw-r--r-- | kittybox-rs/indieauth/src/lib.rs | 116 | ||||
-rw-r--r-- | kittybox-rs/indieauth/src/scopes.rs | 3 | ||||
-rw-r--r-- | kittybox-rs/src/indieauth/mod.rs | 74 |
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!()) |