From 543b4b229f77a35517d2e827d8a0efced6673fff Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 16 Feb 2023 17:12:16 +0100 Subject: [PATCH] Support for token revocation --- crates/handlers/src/lib.rs | 4 + crates/handlers/src/oauth2/discovery.rs | 8 + crates/handlers/src/oauth2/mod.rs | 1 + crates/handlers/src/oauth2/revoke.rs | 200 ++++++++++++++++++++++++ crates/oauth2-types/src/errors.rs | 12 ++ crates/oauth2-types/src/requests.rs | 21 +++ crates/router/src/endpoints.rs | 10 +- crates/router/src/url_builder.rs | 6 + 8 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 crates/handlers/src/oauth2/revoke.rs diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 30519f42..866d49c4 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -180,6 +180,10 @@ where mas_router::OAuth2Introspection::route(), post(self::oauth2::introspection::post), ) + .route( + mas_router::OAuth2Revocation::route(), + post(self::oauth2::revoke::post), + ) .route( mas_router::OAuth2TokenEndpoint::route(), post(self::oauth2::token::post), diff --git a/crates/handlers/src/oauth2/discovery.rs b/crates/handlers/src/oauth2/discovery.rs index 719f0595..35c74a41 100644 --- a/crates/handlers/src/oauth2/discovery.rs +++ b/crates/handlers/src/oauth2/discovery.rs @@ -53,6 +53,7 @@ pub(crate) async fn get( let token_endpoint = Some(url_builder.oauth_token_endpoint()); let jwks_uri = Some(url_builder.jwks_uri()); let introspection_endpoint = Some(url_builder.oauth_introspection_endpoint()); + let revocation_endpoint = Some(url_builder.oauth_revocation_endpoint()); let userinfo_endpoint = Some(url_builder.oidc_userinfo_endpoint()); let registration_endpoint = Some(url_builder.oauth_registration_endpoint()); @@ -76,6 +77,10 @@ pub(crate) async fn get( let token_endpoint_auth_signing_alg_values_supported = client_auth_signing_alg_values_supported.clone(); + let revocation_endpoint_auth_methods_supported = client_auth_methods_supported.clone(); + let revocation_endpoint_auth_signing_alg_values_supported = + client_auth_signing_alg_values_supported.clone(); + let introspection_endpoint_auth_methods_supported = client_auth_methods_supported.map(|v| v.into_iter().map(Into::into).collect()); let introspection_endpoint_auth_signing_alg_values_supported = @@ -125,6 +130,9 @@ pub(crate) async fn get( grant_types_supported, token_endpoint_auth_methods_supported, token_endpoint_auth_signing_alg_values_supported, + revocation_endpoint, + revocation_endpoint_auth_methods_supported, + revocation_endpoint_auth_signing_alg_values_supported, introspection_endpoint, introspection_endpoint_auth_methods_supported, introspection_endpoint_auth_signing_alg_values_supported, diff --git a/crates/handlers/src/oauth2/mod.rs b/crates/handlers/src/oauth2/mod.rs index c4fb43e4..98b79383 100644 --- a/crates/handlers/src/oauth2/mod.rs +++ b/crates/handlers/src/oauth2/mod.rs @@ -18,6 +18,7 @@ pub mod discovery; pub mod introspection; pub mod keys; pub mod registration; +pub mod revoke; pub mod token; pub mod userinfo; pub mod webfinger; diff --git a/crates/handlers/src/oauth2/revoke.rs b/crates/handlers/src/oauth2/revoke.rs new file mode 100644 index 00000000..b97df01c --- /dev/null +++ b/crates/handlers/src/oauth2/revoke.rs @@ -0,0 +1,200 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use axum::{extract::State, response::IntoResponse, Json}; +use hyper::StatusCode; +use mas_axum_utils::{ + client_authorization::{ClientAuthorization, CredentialsVerificationError}, + http_client_factory::HttpClientFactory, +}; +use mas_data_model::TokenType; +use mas_iana::oauth::OAuthTokenTypeHint; +use mas_keystore::Encrypter; +use mas_storage::{BoxClock, BoxRepository}; +use oauth2_types::{ + errors::{ClientError, ClientErrorCode}, + requests::RevocationRequest, +}; +use thiserror::Error; + +use crate::impl_from_error_for_route; + +#[derive(Debug, Error)] +pub(crate) enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("bad request")] + BadRequest, + + #[error("client not found")] + ClientNotFound, + + #[error("client not allowed")] + ClientNotAllowed, + + #[error("could not verify client credentials")] + ClientCredentialsVerification(#[from] CredentialsVerificationError), + + #[error("client is unauthorized")] + UnauthorizedClient, + + #[error("unsupported token type")] + UnsupportedTokenType, + + #[error("unknown token")] + UnknownToken, +} + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + sentry::capture_error(&self); + match self { + Self::Internal(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ClientError::from(ClientErrorCode::ServerError)), + ) + .into_response(), + + Self::BadRequest => ( + StatusCode::BAD_REQUEST, + Json(ClientError::from(ClientErrorCode::InvalidRequest)), + ) + .into_response(), + + Self::ClientNotFound | Self::ClientCredentialsVerification(_) => ( + StatusCode::UNAUTHORIZED, + Json(ClientError::from(ClientErrorCode::InvalidClient)), + ) + .into_response(), + + Self::ClientNotAllowed | Self::UnauthorizedClient => ( + StatusCode::UNAUTHORIZED, + Json(ClientError::from(ClientErrorCode::UnauthorizedClient)), + ) + .into_response(), + + Self::UnsupportedTokenType => ( + StatusCode::BAD_REQUEST, + Json(ClientError::from(ClientErrorCode::UnsupportedTokenType)), + ) + .into_response(), + + // If the token is unknown, we still return a 200 OK response. + Self::UnknownToken => StatusCode::OK.into_response(), + } + } +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl From for RouteError { + fn from(_e: mas_data_model::TokenFormatError) -> Self { + Self::UnknownToken + } +} + +#[tracing::instrument( + name = "handlers.oauth2.revoke.post", + fields(client.id = client_authorization.client_id()), + skip_all, + err, +)] +pub(crate) async fn post( + clock: BoxClock, + State(http_client_factory): State, + mut repo: BoxRepository, + State(encrypter): State, + client_authorization: ClientAuthorization, +) -> Result { + let client = client_authorization + .credentials + .fetch(&mut repo) + .await? + .ok_or(RouteError::ClientNotFound)?; + + let method = client + .token_endpoint_auth_method + .as_ref() + .ok_or(RouteError::ClientNotAllowed)?; + + client_authorization + .credentials + .verify(&http_client_factory, &encrypter, method, &client) + .await?; + + let Some(form) = client_authorization.form else { + return Err(RouteError::BadRequest); + }; + + let token_type = TokenType::check(&form.token)?; + + // Find the ID of the session to end. + let session_id = match (form.token_type_hint, token_type) { + (Some(OAuthTokenTypeHint::AccessToken) | None, TokenType::AccessToken) => { + let access_token = repo + .oauth2_access_token() + .find_by_token(&form.token) + .await? + .ok_or(RouteError::UnknownToken)?; + + if !access_token.is_valid(clock.now()) { + return Err(RouteError::UnknownToken); + } + access_token.session_id + } + + (Some(OAuthTokenTypeHint::RefreshToken) | None, TokenType::RefreshToken) => { + let refresh_token = repo + .oauth2_refresh_token() + .find_by_token(&form.token) + .await? + .ok_or(RouteError::UnknownToken)?; + + if !refresh_token.is_valid() { + return Err(RouteError::UnknownToken); + } + + refresh_token.session_id + } + + // This case can happen if there is a mismatch between the token type hint and the guessed + // token type or if the token was a compat access/refresh token. In those cases, we return + // an unknown token error. + (Some(OAuthTokenTypeHint::AccessToken | OAuthTokenTypeHint::RefreshToken) | None, _) => { + return Err(RouteError::UnknownToken) + } + + (Some(_), _) => return Err(RouteError::UnsupportedTokenType), + }; + + let session = repo + .oauth2_session() + .lookup(session_id) + .await? + .ok_or(RouteError::UnknownToken)?; + + // Check that the client ending the session is the same as the client that + // created it. + if client.id != session.client_id { + return Err(RouteError::UnauthorizedClient); + } + + // Now that we checked eveyrthing, we can end the session. + repo.oauth2_session().finish(&clock, session).await?; + + repo.save().await?; + + Ok(()) +} diff --git a/crates/oauth2-types/src/errors.rs b/crates/oauth2-types/src/errors.rs index 2dd5e632..74535585 100644 --- a/crates/oauth2-types/src/errors.rs +++ b/crates/oauth2-types/src/errors.rs @@ -265,6 +265,15 @@ pub enum ClientErrorCode { /// From [RFC8628](https://www.rfc-editor.org/rfc/rfc8628#section-3.5). ExpiredToken, + /// `unsupported_token_type` + /// + /// The authorization server does not support the revocation of the + /// presented token type. That is, the client tried to revoke an access + /// token on a server not supporting this feature. + /// + /// From [RFC7009](https://www.rfc-editor.org/rfc/rfc7009#section-2.2.1). + UnsupportedTokenType, + /// Another error code. #[display("{0}")] Unknown(String), @@ -353,6 +362,9 @@ impl ClientErrorCode { ClientErrorCode::ExpiredToken => { "The \"device_code\" has expired, and the device authorization session has concluded" } + ClientErrorCode::UnsupportedTokenType => { + "The authorization server does not support the revocation of the presented token type." + }, ClientErrorCode::Unknown(_) => "", } } diff --git a/crates/oauth2-types/src/requests.rs b/crates/oauth2-types/src/requests.rs index 6ff1b58a..cc977a72 100644 --- a/crates/oauth2-types/src/requests.rs +++ b/crates/oauth2-types/src/requests.rs @@ -716,6 +716,27 @@ pub struct IntrospectionResponse { pub jti: Option, } +/// A request to the [Revocation Endpoint]. +/// +/// [Revocation Endpoint]: https://www.rfc-editor.org/rfc/rfc7009#section-2 +#[skip_serializing_none] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct RevocationRequest { + /// The value of the token. + pub token: String, + + /// A hint about the type of the token submitted for introspection. + pub token_type_hint: Option, +} + +impl fmt::Debug for RevocationRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RevocationRequest") + .field("token_type_hint", &self.token_type_hint) + .finish_non_exhaustive() + } +} + /// A successful response from the [Pushed Authorization Request Endpoint]. /// /// Note that there is no request type because it is by definition the same as diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index dbfe301a..308abd9c 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -91,7 +91,7 @@ impl SimpleRoute for OidcUserinfo { const PATH: &'static str = "/oauth2/userinfo"; } -/// `POST /oauth2/userinfo` +/// `POST /oauth2/introspect` #[derive(Default, Debug, Clone)] pub struct OAuth2Introspection; @@ -99,6 +99,14 @@ impl SimpleRoute for OAuth2Introspection { const PATH: &'static str = "/oauth2/introspect"; } +/// `POST /oauth2/revoke` +#[derive(Default, Debug, Clone)] +pub struct OAuth2Revocation; + +impl SimpleRoute for OAuth2Revocation { + const PATH: &'static str = "/oauth2/revoke"; +} + /// `POST /oauth2/token` #[derive(Default, Debug, Clone)] pub struct OAuth2TokenEndpoint; diff --git a/crates/router/src/url_builder.rs b/crates/router/src/url_builder.rs index 35888a34..7702e04f 100644 --- a/crates/router/src/url_builder.rs +++ b/crates/router/src/url_builder.rs @@ -75,6 +75,12 @@ impl UrlBuilder { self.url_for(&crate::endpoints::OAuth2Introspection) } + /// OAuth 2.0 revocation endpoint + #[must_use] + pub fn oauth_revocation_endpoint(&self) -> Url { + self.url_for(&crate::endpoints::OAuth2Revocation) + } + /// OAuth 2.0 client registration endpoint #[must_use] pub fn oauth_registration_endpoint(&self) -> Url {