From 237accf0a1313ff5d73fe18096dd7229e0f8ba62 Mon Sep 17 00:00:00 2001 From: Jesse Morgan Date: Sun, 20 Mar 2022 17:39:10 -0700 Subject: Initial commit --- .gitignore | 5 ++ Cargo.toml | 21 ++++++ src/demo/main.rs | 47 +++++++++++++ src/error.rs | 53 +++++++++++++++ src/lib.rs | 202 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 328 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/demo/main.rs create mode 100644 src/error.rs create mode 100644 src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5da13db --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +Cargo.lock +.idea +*.iml +.*.sw[lmnop] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..dbb765c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "actix-middleware-rfc7662" +version = "0.1.0" +edition = "2021" +description = "Actix-web extractor which validates OAuth2 tokens through an RFC 7662 token introspection endpoint." +license = "MIT" +repository = "https://github.com/jesterpm/actix-middleware-rfc7662" +authors = ["Jesse Morgan "] + +[[bin]] +name = "demo" +path = "src/demo/main.rs" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +log = "0.4" +actix-web = "4" +awc = "3" +oauth2 = "4" +futures-util = "0.3" \ No newline at end of file diff --git a/src/demo/main.rs b/src/demo/main.rs new file mode 100644 index 0000000..f598b84 --- /dev/null +++ b/src/demo/main.rs @@ -0,0 +1,47 @@ +use actix_middleware_rfc7662::{ + AnyScope, RequireAuthorization, RequireAuthorizationConfig, RequireScope, +}; +use actix_web::{get, HttpResponse, HttpServer, Responder}; + +#[get("/read")] +async fn handle_read(_auth: RequireAuthorization) -> impl Responder { + HttpResponse::Ok().body("Success!\n") +} + +struct WriteScope; +impl RequireScope for WriteScope { + fn scope() -> &'static str { + "write" + } +} + +#[get("/write")] +async fn handle_write(_auth: RequireAuthorization) -> impl Responder { + HttpResponse::Ok().body("Success!\n") +} + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + let bind = std::env::var("BIND").unwrap_or_else(|_| "127.0.0.1:8182".to_string()); + + let oauth_config = RequireAuthorizationConfig::new( + "cid1".to_string(), + Some("cs1".to_string()), + "https://cadmium.jesterpm.net/oauth/authorize" + .parse() + .expect("invalid url"), + "https://cadmium.jesterpm.net/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(bind)? + .run() + .await +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..271dc91 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,53 @@ +use actix_web::http::{header, StatusCode}; +use actix_web::{HttpResponse, ResponseError}; +use std::fmt::{Display, Formatter}; + +#[derive(Debug, Copy, Clone)] +pub enum Error { + MissingToken, + InvalidToken, + ConfigurationError, + IntrospectionServerError, + AccessDenied, +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Error::AccessDenied => "Access denied", + Error::MissingToken => "Missing authorization token", + Error::InvalidToken => "Invalid access token", + Error::ConfigurationError => "OAuth2 client configuration error", + Error::IntrospectionServerError => "Introspection endpoint returned an error", + }) + } +} + +impl ResponseError for Error { + fn status_code(&self) -> StatusCode { + match self { + Error::AccessDenied => StatusCode::FORBIDDEN, + Error::MissingToken => StatusCode::UNAUTHORIZED, + Error::InvalidToken => StatusCode::UNAUTHORIZED, + Error::ConfigurationError => StatusCode::INTERNAL_SERVER_ERROR, + Error::IntrospectionServerError => StatusCode::SERVICE_UNAVAILABLE, + } + } + + fn error_response(&self) -> HttpResponse { + let mut resp = HttpResponse::build(self.status_code()); + match self { + Error::AccessDenied => { + resp.insert_header((header::WWW_AUTHENTICATE, "Bearer")); + resp.body("{\"error\": \"insufficient_scope\"}") + } + Error::MissingToken => resp.finish(), + Error::InvalidToken => { + resp.insert_header((header::WWW_AUTHENTICATE, "Bearer")); + resp.body("{\"error\": \"invalid_token\"}") + } + Error::ConfigurationError => resp.finish(), + Error::IntrospectionServerError => resp.finish(), + } + } +} 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) -> 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; + +pub trait AuthorizationRequirements { + fn authorized(introspection: &IntrospectionResponse) -> Result; +} + +pub trait RequireScope { + fn scope() -> &'static str; +} + +impl AuthorizationRequirements for T +where + T: RequireScope, +{ + fn authorized(introspection: &IntrospectionResponse) -> Result { + 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 { + Ok(true) + } +} + +pub struct RequireAuthorization +where + T: AuthorizationRequirements, +{ + introspection: IntrospectionResponse, + _auth_marker: PhantomData, +} + +impl RequireAuthorization +where + T: AuthorizationRequirements, +{ + pub fn introspection(&self) -> &IntrospectionResponse { + &self.introspection + } +} + +impl FromRequest for RequireAuthorization +where + T: AuthorizationRequirements + 'static, +{ + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + fn from_request(req: &actix_web::HttpRequest, _: &mut dev::Payload) -> Self::Future { + let verifier = if let Some(verifier) = req.app_data::() { + 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, + StandardTokenResponse, + BasicTokenType, + StandardTokenIntrospectionResponse, + StandardRevocableToken, + StandardErrorResponse, + >, +} + +#[derive(Clone)] +pub struct RequireAuthorizationConfig(Arc); + +impl RequireAuthorizationConfig { + pub fn new( + client_id: String, + client_secret: Option, + 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 { + 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) + } + }) + } +} -- cgit v1.2.3