summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJesse Morgan <jesse@jesterpm.net>2022-03-20 17:39:10 -0700
committerJesse Morgan <jesse@jesterpm.net>2022-03-20 17:39:10 -0700
commit237accf0a1313ff5d73fe18096dd7229e0f8ba62 (patch)
treea7b8660ab4555706cf0f0c42f758f7afc46524eb
Initial commit
-rw-r--r--.gitignore5
-rw-r--r--Cargo.toml21
-rw-r--r--src/demo/main.rs47
-rw-r--r--src/error.rs53
-rw-r--r--src/lib.rs202
5 files changed, 328 insertions, 0 deletions
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 <jesse@jesterpm.net>"]
+
+[[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<AnyScope>) -> 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<WriteScope>) -> 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<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)
+ }
+ })
+ }
+}