diff options
Diffstat (limited to 'src/lib.rs')
| -rw-r--r-- | src/lib.rs | 202 |
1 files changed, 202 insertions, 0 deletions
diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..670a397 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,202 @@ +//! Actix-web extractor which validates OAuth2 tokens through an +//! [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) token +//! introspection endpoint. +//! +//! To protect a resource, you add the `RequireAuthorization` extractor. +//! This extractor must be configured with a token introspection url +//! before it can be used. +//! +//! The extractor takes an implementation of the +//! `AuthorizationRequirements` trait, which is used to analyze the +//! introspection response to determine if the request is authorized. +//! +//! # Example +//! ``` +//! # use actix_web::{ get, HttpResponse, HttpServer, Responder }; +//! # use actix_middleware_rfc7662::{AnyScope, RequireAuthorization, RequireAuthorizationConfig}; +//! +//! #[get("/protected/api")] +//! async fn handle_read(_auth: RequireAuthorization<AnyScope>) -> impl Responder { +//! HttpResponse::Ok().body("Success!\n") +//! } +//! +//! #[actix_web::main] +//! async fn main() -> std::io::Result<()> { +//! let oauth_config = RequireAuthorizationConfig::new( +//! "client_id".to_string(), +//! Some("client_secret".to_string()), +//! "https://example.com/oauth/authorize".parse().expect("invalid url"), +//! "https://example.com/oauth/introspect".parse().expect("invalid url"), +//! ); +//! +//! HttpServer::new(move || { +//! actix_web::App::new() +//! .app_data(oauth_config.clone()) +//! .service(handle_read) +//! .service(handle_write) +//! }) +//! .bind("127.0.0.1:8182".to_string())? +//! .run() +//! .await +//! } +//! ``` + +use actix_web::{dev, FromRequest, HttpRequest}; +use futures_util::future::LocalBoxFuture; +use oauth2::basic::{BasicErrorResponseType, BasicTokenType}; +use oauth2::url::Url; +use oauth2::*; +use std::future::ready; +use std::marker::PhantomData; +use std::sync::Arc; + +mod error; +use error::Error; + +const BEARER_TOKEN_PREFIX: &str = "Bearer "; + +pub type IntrospectionResponse = + StandardTokenIntrospectionResponse<EmptyExtraTokenFields, BasicTokenType>; + +pub trait AuthorizationRequirements { + fn authorized(introspection: &IntrospectionResponse) -> Result<bool, Error>; +} + +pub trait RequireScope { + fn scope() -> &'static str; +} + +impl<T> AuthorizationRequirements for T +where + T: RequireScope, +{ + fn authorized(introspection: &IntrospectionResponse) -> Result<bool, Error> { + Ok(introspection + .scopes() + .map(|s| s.iter().find(|s| s.as_ref() == T::scope()).is_some()) + .unwrap_or(false)) + } +} + +pub struct AnyScope; + +impl AuthorizationRequirements for AnyScope { + fn authorized(_: &IntrospectionResponse) -> Result<bool, Error> { + Ok(true) + } +} + +pub struct RequireAuthorization<T> +where + T: AuthorizationRequirements, +{ + introspection: IntrospectionResponse, + _auth_marker: PhantomData<T>, +} + +impl<T> RequireAuthorization<T> +where + T: AuthorizationRequirements, +{ + pub fn introspection(&self) -> &IntrospectionResponse { + &self.introspection + } +} + +impl<T> FromRequest for RequireAuthorization<T> +where + T: AuthorizationRequirements + 'static, +{ + type Error = Error; + type Future = LocalBoxFuture<'static, Result<Self, Self::Error>>; + + fn from_request(req: &actix_web::HttpRequest, _: &mut dev::Payload) -> Self::Future { + let verifier = if let Some(verifier) = req.app_data::<RequireAuthorizationConfig>() { + verifier.clone() + } else { + return Box::pin(ready(Err(Error::ConfigurationError))); + }; + + let my_req = req.clone(); + + Box::pin(async move { + verifier + .verify_request(my_req) + .await + .and_then(|introspection| { + if T::authorized(&introspection)? { + Ok(RequireAuthorization { + introspection, + _auth_marker: PhantomData::default(), + }) + } else { + Err(Error::AccessDenied) + } + }) + }) + } +} + +#[derive(Clone)] +struct RequireAuthorizationConfigInner { + client: oauth2::Client< + StandardErrorResponse<BasicErrorResponseType>, + StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>, + BasicTokenType, + StandardTokenIntrospectionResponse<EmptyExtraTokenFields, BasicTokenType>, + StandardRevocableToken, + StandardErrorResponse<BasicErrorResponseType>, + >, +} + +#[derive(Clone)] +pub struct RequireAuthorizationConfig(Arc<RequireAuthorizationConfigInner>); + +impl RequireAuthorizationConfig { + pub fn new( + client_id: String, + client_secret: Option<String>, + auth_url: Url, + introspection_url: Url, + ) -> Self { + let client = oauth2::Client::new( + ClientId::new(client_id), + client_secret.map(|s| ClientSecret::new(s)), + AuthUrl::from_url(auth_url), + None, + ) + .set_introspection_uri(IntrospectionUrl::from_url(introspection_url)); + RequireAuthorizationConfig(Arc::new(RequireAuthorizationConfigInner { client })) + } + + async fn verify_request(&self, req: HttpRequest) -> Result<IntrospectionResponse, Error> { + let access_token = req + .headers() + .get("Authorization") + .and_then(|value| value.to_str().ok()) + .filter(|value| value.starts_with(BEARER_TOKEN_PREFIX)) + .map(|value| AccessToken::new(value.split_at(BEARER_TOKEN_PREFIX.len()).1.to_string())) + .ok_or(Error::MissingToken)?; + + self.0 + .client + .introspect(&access_token) + .map_err(|e| { + log::error!("OAuth2 client configuration error: {}", e); + Error::ConfigurationError + })? + .request_async(reqwest::async_http_client) + .await + .map_err(|e| { + log::warn!("Error from token introspection service: {}", e); + Error::IntrospectionServerError + }) + .and_then(|resp| { + if resp.active() { + Ok(resp) + } else { + Err(Error::InvalidToken) + } + }) + } +} |
