├── .gitignore ├── .gitmodules ├── .fossa.yml ├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ └── ci.yml ├── src ├── reqwest_middleware.rs ├── lib.rs ├── protocol_test_suite.rs ├── sign_outgoing.rs ├── config.rs ├── axum_service.rs └── validate_incoming.rs ├── LICENSE.txt ├── Cargo.toml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | .idea 4 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "mauth-protocol-test-suite"] 2 | path = mauth-protocol-test-suite 3 | url = https://github.com/mdsol/mauth-protocol-test-suite.git 4 | -------------------------------------------------------------------------------- /.fossa.yml: -------------------------------------------------------------------------------- 1 | # Generated by FOSSA CLI (https://github.com/fossas/fossa-cli) 2 | # Visit https://fossa.com to learn more 3 | 4 | version: 2 5 | cli: 6 | server: https://app.fossa.com 7 | fetcher: custom 8 | project: mauth-client-rust 9 | analyze: 10 | modules: 11 | - name: Cargo.toml 12 | type: cargo 13 | target: . 14 | path: . 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | 8 | updates: 9 | - package-ecosystem: cargo 10 | directory: / 11 | schedule: 12 | interval: weekly 13 | 14 | - package-ecosystem: github-actions 15 | directory: / 16 | schedule: 17 | interval: weekly 18 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - "*.*.*" 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | name: Build + Publish 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | submodules: true 24 | 25 | - name: Setup Rust toolchain 26 | uses: dtolnay/rust-toolchain@stable 27 | 28 | - run: cargo publish 29 | env: 30 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 31 | -------------------------------------------------------------------------------- /src/reqwest_middleware.rs: -------------------------------------------------------------------------------- 1 | use http::Extensions; 2 | use reqwest::{Request, Response}; 3 | use reqwest_middleware::{Middleware, Next, Result}; 4 | 5 | use crate::{MAuthInfo, sign_outgoing::SigningError}; 6 | 7 | #[async_trait::async_trait] 8 | impl Middleware for MAuthInfo { 9 | async fn handle( 10 | &self, 11 | mut req: Request, 12 | extensions: &mut Extensions, 13 | next: Next<'_>, 14 | ) -> Result { 15 | self.sign_request(&mut req)?; 16 | next.run(req, extensions).await 17 | } 18 | } 19 | 20 | impl From for reqwest_middleware::Error { 21 | fn from(value: SigningError) -> Self { 22 | reqwest_middleware::Error::Middleware(value.into()) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Medidata Solutions Worldwide 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | pull_request: 9 | 10 | env: 11 | CARGO_TERM_COLOR: always 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | rustfmt: 18 | name: rustfmt 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions-rust-lang/setup-rust-toolchain@v1 23 | with: 24 | components: rustfmt 25 | - name: Rustfmt Check 26 | uses: actions-rust-lang/rustfmt@v1 27 | 28 | clippy: 29 | name: clippy 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v4 33 | with: 34 | submodules: true 35 | - uses: actions-rust-lang/setup-rust-toolchain@v1 36 | with: 37 | components: clippy 38 | - name: Clippy Check 39 | run: cargo clippy --all-targets --all-features 40 | 41 | test: 42 | runs-on: ubuntu-latest 43 | timeout-minutes: 10 44 | 45 | concurrency: 46 | # Cancel intermediate builds 47 | group: ${{ github.workflow }}-${{ github.ref }} 48 | cancel-in-progress: true 49 | 50 | steps: 51 | - uses: actions/checkout@v4 52 | with: 53 | submodules: true 54 | 55 | - name: Setup Rust toolchain 56 | uses: dtolnay/rust-toolchain@stable 57 | 58 | - uses: Swatinem/rust-cache@v2 59 | 60 | - name: Run tests 61 | run: | 62 | cargo test --all-features 63 | 64 | - name: Publication Dry Run 65 | run: cargo publish --dry-run 66 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![forbid(unsafe_code)] 2 | #![doc = include_str!("../README.md")] 3 | 4 | use ::reqwest_middleware::ClientWithMiddleware; 5 | use mauth_core::signer::Signer; 6 | use mauth_core::verifier::Verifier; 7 | use reqwest::Url; 8 | use std::collections::HashMap; 9 | use std::sync::{LazyLock, OnceLock, RwLock}; 10 | use uuid::Uuid; 11 | 12 | /// This is the primary struct of this class. It contains all of the information 13 | /// required to sign requests using the MAuth protocol and verify the responses. 14 | /// 15 | /// Note that it contains a cache of response keys for verifying response signatures. This cache 16 | /// makes the struct non-Sync. 17 | #[derive(Clone)] 18 | pub struct MAuthInfo { 19 | app_id: Uuid, 20 | sign_with_v1_also: bool, 21 | signer: Signer, 22 | mauth_uri_base: Url, 23 | allow_v1_auth: bool, 24 | } 25 | 26 | static CLIENT: OnceLock = OnceLock::new(); 27 | 28 | static PUBKEY_CACHE: LazyLock>> = 29 | LazyLock::new(|| RwLock::new(HashMap::new())); 30 | 31 | /// Tower Service and Layer to allow Tower-integrated servers to validate incoming request 32 | #[cfg(feature = "axum-service")] 33 | pub mod axum_service; 34 | /// Helpers to parse configuration files or supply structs and construct instances of the main struct 35 | pub mod config; 36 | #[cfg(test)] 37 | mod protocol_test_suite; 38 | mod reqwest_middleware; 39 | /// Implementation of code to sign outgoing requests 40 | pub mod sign_outgoing; 41 | /// Implementation of code to validate incoming requests 42 | #[cfg(feature = "axum-service")] 43 | pub mod validate_incoming; 44 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mauth-client" 3 | version = "0.7.0" 4 | authors = ["Mason Gup "] 5 | edition = "2024" 6 | rust-version = "1.88" 7 | documentation = "https://docs.rs/mauth-client/" 8 | license = "MIT" 9 | description = "Sign requests and validate responses using the Medidata MAuth protocol" 10 | readme = "README.md" 11 | homepage = "https://github.com/mdsol/mauth-client-rust" 12 | repository = "https://github.com/mdsol/mauth-client-rust" 13 | keywords = ["security", "authentication", "web"] 14 | categories = ["authentication", "web-programming"] 15 | 16 | [dependencies] 17 | reqwest = { version = "0.12", features = ["json"] } 18 | reqwest-middleware = "0.4" 19 | reqwest-tracing = { version = "0.5.8", optional = true } 20 | async-trait = ">= 0.1.83" 21 | url = "2" 22 | serde = { version = "1", features = ["derive"] } 23 | serde_json = "1" 24 | serde_yml = ">= 0.0.10" 25 | uuid = { version = "1", features = ["v4"] } 26 | dirs = "5" 27 | chrono = "0.4" 28 | tokio = { version = "1", features = ["fs"] } 29 | tower = { version = ">= 0.4", optional = true } 30 | axum = { version = ">= 0.8", optional = true } 31 | futures-core = { version = "0.3", optional = true } 32 | http = "1" 33 | bytes = { version = "1", optional = true } 34 | thiserror = "1" 35 | mauth-core = "0.6" 36 | tracing = { version = "0.1", optional = true } 37 | 38 | [dev-dependencies] 39 | tokio = { version = "1", features = ["rt-multi-thread", "macros"] } 40 | 41 | [features] 42 | axum-service = ["tower", "futures-core", "axum", "bytes", "tracing"] 43 | tracing-otel-26 = ["reqwest-tracing/opentelemetry_0_26"] 44 | tracing-otel-27 = ["reqwest-tracing/opentelemetry_0_27"] 45 | tracing-otel-28 = ["reqwest-tracing/opentelemetry_0_28"] 46 | tracing-otel-29 = ["reqwest-tracing/opentelemetry_0_29"] 47 | tracing-otel-30 = ["reqwest-tracing/opentelemetry_0_30"] 48 | -------------------------------------------------------------------------------- /src/protocol_test_suite.rs: -------------------------------------------------------------------------------- 1 | use crate::{MAuthInfo, config::ConfigFileSection}; 2 | use reqwest::{Method, Request}; 3 | use serde::Deserialize; 4 | use tokio::fs; 5 | 6 | use std::path::{Path, PathBuf}; 7 | 8 | #[derive(Deserialize)] 9 | struct TestSignConfig { 10 | app_uuid: String, 11 | request_time: u64, 12 | private_key_file: String, 13 | } 14 | 15 | const BASE_PATH: &str = "mauth-protocol-test-suite/protocols/MWSV2/"; 16 | 17 | async fn setup_mauth_info() -> (MAuthInfo, u64) { 18 | let config_path = Path::new("mauth-protocol-test-suite/signing-config.json"); 19 | let sign_config: TestSignConfig = 20 | serde_json::from_slice(&fs::read(config_path).await.unwrap()).unwrap(); 21 | let mock_config_section = ConfigFileSection { 22 | app_uuid: sign_config.app_uuid, 23 | mauth_baseurl: "https://www.example.com/".to_string(), 24 | mauth_api_version: "v1".to_string(), 25 | private_key_file: Some(format!( 26 | "mauth-protocol-test-suite{}", 27 | sign_config.private_key_file.replace('.', "") 28 | )), 29 | private_key_data: None, 30 | v2_only_sign_requests: None, 31 | v2_only_authenticate: None, 32 | }; 33 | ( 34 | MAuthInfo::from_config_section(&mock_config_section).unwrap(), 35 | sign_config.request_time, 36 | ) 37 | } 38 | 39 | async fn test_generate_headers(file_name: String) { 40 | let (mauth_info, req_time) = setup_mauth_info().await; 41 | 42 | let mut sig_file_path = PathBuf::from(&BASE_PATH); 43 | sig_file_path.push(format!("{name}/{name}.sig", name = &file_name)); 44 | let sig = String::from_utf8(fs::read(sig_file_path).await.unwrap()).unwrap(); 45 | 46 | let mut authz_file_path = PathBuf::from(&BASE_PATH); 47 | authz_file_path.push(format!("{name}/{name}.authz", name = &file_name)); 48 | let auth_headers: serde_json::Value = 49 | serde_json::from_slice(&fs::read(authz_file_path).await.unwrap()).unwrap(); 50 | 51 | let mut request = Request::new(Method::GET, url::Url::parse("http://www.a.com/").unwrap()); 52 | mauth_info.set_headers_v2(&mut request, sig, &req_time.to_string()); 53 | 54 | let headers = request.headers(); 55 | let time_header = headers.get("MCC-Time").unwrap().to_str().unwrap(); 56 | let sig_header = headers.get("MCC-Authentication").unwrap().to_str().unwrap(); 57 | 58 | let expected_time = auth_headers 59 | .get("MCC-Time") 60 | .unwrap() 61 | .as_u64() 62 | .unwrap() 63 | .to_string(); 64 | let expected_sig = auth_headers 65 | .get("MCC-Authentication") 66 | .unwrap() 67 | .as_str() 68 | .unwrap(); 69 | 70 | assert_eq!(expected_time, time_header); 71 | assert_eq!(expected_sig, sig_header); 72 | } 73 | 74 | include!(concat!(env!("OUT_DIR"), "/protocol_tests.rs")); 75 | -------------------------------------------------------------------------------- /src/sign_outgoing.rs: -------------------------------------------------------------------------------- 1 | use crate::MAuthInfo; 2 | use chrono::prelude::*; 3 | use reqwest::{Request, header::HeaderValue}; 4 | use thiserror::Error; 5 | 6 | impl MAuthInfo { 7 | /// This method determines how to sign the request automatically while respecting the 8 | /// `v2_only_sign_requests` flag in the config file. It always signs with the V2 algorithm and 9 | /// signature, and will also sign with the V1 algorithm, if the configuration permits. 10 | /// 11 | /// Note that, as the request signature includes a timestamp, the request must be sent out 12 | /// shortly after the signature takes place. 13 | /// 14 | /// Note that it will need to read the entire body in order to sign it, so it will not 15 | /// work properly if any of the streaming body types are used. 16 | pub fn sign_request(&self, req: &mut Request) -> Result<(), SigningError> { 17 | self.sign_request_v2(req)?; 18 | if self.sign_with_v1_also { 19 | self.sign_request_v1(req)?; 20 | } 21 | Ok(()) 22 | } 23 | 24 | /// Sign a provided request using the MAuth V2 protocol. The signature consists of 2 headers 25 | /// containing both a timestamp and a signature string, and will be added to the headers of the 26 | /// request. It is required to pass a `body_digest` computed by the 27 | /// [`build_body_with_digest`](#method.build_body_with_digest) method, even if the request is 28 | /// an empty-body GET. 29 | /// 30 | /// Note that, as the request signature includes a timestamp, the request must be sent out 31 | /// shortly after the signature takes place. 32 | /// 33 | /// Also note that it will need to read the entire body in order to sign it, so it will not 34 | /// work properly if any of the streaming body types are used. 35 | pub fn sign_request_v2(&self, req: &mut Request) -> Result<(), SigningError> { 36 | let timestamp_str = Utc::now().timestamp().to_string(); 37 | let body_data = match req.body() { 38 | None => &[], 39 | Some(reqwest_body) => reqwest_body.as_bytes().unwrap_or(&[]), 40 | }; 41 | let some_string = self.signer.sign_string( 42 | 2, 43 | req.method().as_str(), 44 | req.url().path(), 45 | req.url().query().unwrap_or(""), 46 | body_data, 47 | timestamp_str.clone(), 48 | )?; 49 | self.set_headers_v2(req, some_string, ×tamp_str); 50 | Ok(()) 51 | } 52 | 53 | pub(crate) fn set_headers_v2(&self, req: &mut Request, signature: String, timestamp_str: &str) { 54 | let sig_head_str = format!("MWSV2 {}:{};", self.app_id, &signature); 55 | let headers = req.headers_mut(); 56 | headers.insert("MCC-Time", HeaderValue::from_str(timestamp_str).unwrap()); 57 | headers.insert( 58 | "MCC-Authentication", 59 | HeaderValue::from_str(&sig_head_str).unwrap(), 60 | ); 61 | } 62 | 63 | /// Sign a provided request using the MAuth V1 protocol. The signature consists of 2 headers 64 | /// containing both a timestamp and a signature string, and will be added to the headers of the 65 | /// request. It is required to pass a `body`, even if the request is an empty-body GET. 66 | /// 67 | /// Note that, as the request signature includes a timestamp, the request must be sent out 68 | /// shortly after the signature takes place. 69 | /// 70 | /// Also note that it will need to read the entire body in order to sign it, so it will not 71 | /// work properly if any of the streaming body types are used. 72 | pub fn sign_request_v1(&self, req: &mut Request) -> Result<(), SigningError> { 73 | let timestamp_str = Utc::now().timestamp().to_string(); 74 | 75 | let body_data = match req.body() { 76 | None => &[], 77 | Some(reqwest_body) => reqwest_body.as_bytes().unwrap_or(&[]), 78 | }; 79 | 80 | let sig = self.signer.sign_string( 81 | 1, 82 | req.method().as_str(), 83 | req.url().path(), 84 | req.url().query().unwrap_or(""), 85 | body_data, 86 | timestamp_str.clone(), 87 | )?; 88 | 89 | let headers = req.headers_mut(); 90 | headers.insert("X-MWS-Time", HeaderValue::from_str(×tamp_str).unwrap()); 91 | headers.insert("X-MWS-Authentication", HeaderValue::from_str(&sig).unwrap()); 92 | Ok(()) 93 | } 94 | } 95 | 96 | /// All of the errors that can take place while attempting to sign a request 97 | #[derive(Debug, Error)] 98 | pub enum SigningError { 99 | #[error("Unable to handle the URL as the format was invalid: {0}")] 100 | UrlEncodingError(std::string::FromUtf8Error), 101 | } 102 | 103 | impl From for SigningError { 104 | fn from(err: mauth_core::error::Error) -> SigningError { 105 | match err { 106 | mauth_core::error::Error::UrlEncodingError(url_err) => { 107 | SigningError::UrlEncodingError(url_err) 108 | } 109 | _ => panic!("should not be possible to get this error type from signing a request"), 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mauth-client 2 | 3 | This crate allows users of the Reqwest crate for making HTTP requests to sign those requests with 4 | the MAuth protocol, and verify the responses. Usage example: 5 | 6 | **Note**: This crate and Rust support within Medidata is considered experimental. Do not 7 | release any code to Production or deploy in a Client-accessible environment without getting 8 | approval for the full stack used through the Architecture and Security groups. 9 | 10 | ## Outgoing Requests 11 | 12 | ```no_run 13 | use mauth_client::MAuthInfo; 14 | use reqwest::Client; 15 | # async fn send_request() { 16 | let mauth_info = MAuthInfo::from_default_file().unwrap(); 17 | let client = Client::new(); 18 | let mut req = client.get("https://www.example.com/").build().unwrap(); 19 | mauth_info.sign_request(&mut req); 20 | match client.execute(req).await { 21 | Err(err) => println!("Got error {}", err), 22 | Ok(response) => println!("Got validated response with body {}", response.text().await.unwrap()), 23 | } 24 | # } 25 | ``` 26 | 27 | The above code will read your mauth configuration from a file in `~/.mauth_config.yml` which format is: 28 | ```yaml 29 | common: &common 30 | mauth_baseurl: https:// 31 | mauth_api_version: v1 32 | app_uuid: 33 | private_key_file: 34 | ``` 35 | 36 | The `MAuthInfo` struct also functions as a outgoing middleware using the 37 | [`reqwest-middleware`](https://crates.io/crates/reqwest-middleware) crate for a simpler API and easier 38 | integration with other outgoing middleware: 39 | 40 | ```no_run 41 | use mauth_client::MAuthInfo; 42 | use reqwest::Client; 43 | use reqwest_middleware::ClientBuilder; 44 | # async fn send_request() { 45 | let mauth_info = MAuthInfo::from_default_file().unwrap(); 46 | let client = ClientBuilder::new(Client::new()).with(mauth_info).build(); 47 | match client.get("https://www.example.com/").send().await { 48 | Err(err) => println!("Got error {}", err), 49 | Ok(response) => println!("Got validated response with body {}", response.text().await.unwrap()), 50 | } 51 | # } 52 | ``` 53 | 54 | ## Incoming Requests 55 | 56 | The optional `axum-service` feature provides for a Tower Layer and Service that will 57 | authenticate incoming requests via MAuth V2 or V1 and provide to the lower layers a 58 | validated app_uuid from the request via the `ValidatedRequestDetails` struct. Note that 59 | this feature now includes a `RequiredMAuthValidationLayer`, which will reject any 60 | requests without a valid signature before they reach lower layers, and also a 61 | `OptionalMAuthValidationLayer`, which lets all requests through, but only attaches a 62 | `ValidatedRequestDetails` extension struct if there is a valid signature. When using this 63 | layer, it is the responsiblity of the request handler to check for the extension and 64 | reject requests that are not properly authorized. 65 | 66 | Note that `ValidatedRequestDetails` implements Axum's `FromRequestParts`, so you can 67 | specify it bare in a request handler. This implementation includes returning a 401 68 | Unauthorized status code if the extension is not present. If you would like to return 69 | a different response, or respond to the lack of the extension in another way, you can 70 | use a more manual mechanism to check for the extension and decide how to proceed if it 71 | is not present. 72 | 73 | ### Examples for `RequiredMAuthValidationLayer` 74 | 75 | ```no_run 76 | # async fn run_server() { 77 | use mauth_client::{ 78 | axum_service::RequiredMAuthValidationLayer, 79 | validate_incoming::ValidatedRequestDetails, 80 | }; 81 | use axum::{http::StatusCode, Router, routing::get, serve}; 82 | use tokio::net::TcpListener; 83 | 84 | // If there is not a valid mauth signature, this function will never run at all, and 85 | // the request will return an empty 401 Unauthorized 86 | async fn foo() -> StatusCode { 87 | StatusCode::OK 88 | } 89 | 90 | // In addition to returning a 401 Unauthorized without running if there is not a valid 91 | // MAuth signature, this also makes the validated requesting app UUID available to 92 | // the function 93 | async fn bar(details: ValidatedRequestDetails) -> StatusCode { 94 | println!("Got a request from app with UUID: {}", details.app_uuid); 95 | StatusCode::OK 96 | } 97 | 98 | // This function will run regardless of whether or not there is a mauth signature 99 | async fn baz() -> StatusCode { 100 | StatusCode::OK 101 | } 102 | 103 | // Attaching the baz route handler after the layer means the layer is not run for 104 | // requests to that path, so no mauth checking will be performed for that route and 105 | // any other routes attached after the layer 106 | let router = Router::new() 107 | .route("/foo", get(foo)) 108 | .route("/bar", get(bar)) 109 | .layer(RequiredMAuthValidationLayer::from_default_file().unwrap()) 110 | .route("/baz", get(baz)); 111 | let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap(); 112 | serve(listener, router).await.unwrap(); 113 | # } 114 | ``` 115 | 116 | ### Examples for `OptionalMAuthValidationLayer` 117 | 118 | ```no_run 119 | # async fn run_server() { 120 | use mauth_client::{ 121 | axum_service::OptionalMAuthValidationLayer, 122 | validate_incoming::ValidatedRequestDetails, 123 | }; 124 | use axum::{http::StatusCode, Router, routing::get, serve}; 125 | use tokio::net::TcpListener; 126 | 127 | // This request will run no matter what the authorization status is 128 | async fn foo() -> StatusCode { 129 | StatusCode::OK 130 | } 131 | 132 | // If there is not a valid mauth signature, this function will never run at all, and 133 | // the request will return an empty 401 Unauthorized 134 | async fn bar(_: ValidatedRequestDetails) -> StatusCode { 135 | StatusCode::OK 136 | } 137 | 138 | // In addition to returning a 401 Unauthorized without running if there is not a valid 139 | // MAuth signature, this also makes the validated requesting app UUID available to 140 | // the function 141 | async fn baz(details: ValidatedRequestDetails) -> StatusCode { 142 | println!("Got a request from app with UUID: {}", details.app_uuid); 143 | StatusCode::OK 144 | } 145 | 146 | // This request will run whether or not there is a valid mauth signature, but the Option 147 | // provided can be used to tell you whether there was a valid signature, so you can 148 | // implement things like multiple possible types of authentication or behavior other than 149 | // a 401 return if there is no authentication 150 | async fn bam(optional_details: Option) -> StatusCode { 151 | match optional_details { 152 | Some(details) => println!("Got a request from app with UUID: {}", details.app_uuid), 153 | None => println!("Got a request without a valid mauth signature"), 154 | } 155 | StatusCode::OK 156 | } 157 | 158 | let router = Router::new() 159 | .route("/foo", get(foo)) 160 | .route("/bar", get(bar)) 161 | .route("/baz", get(baz)) 162 | .route("/bam", get(bam)) 163 | .layer(OptionalMAuthValidationLayer::from_default_file().unwrap()); 164 | let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap(); 165 | serve(listener, router).await.unwrap(); 166 | # } 167 | ``` 168 | 169 | ### Error Handling 170 | 171 | Both the `RequiredMAuthValidationLayer` and the `OptionalMAuthValidationLayer` layers will 172 | log errors encountered via `tracing` under the `mauth_client::validate_incoming` target. 173 | 174 | The Required layer returns the 401 response immediately, so there is no convenient way to 175 | retrieve the error in order to do anything more sophisticated with it. 176 | 177 | The Optional layer, in addition to loging the error, will also add the `MAuthValidationError` 178 | to the request extensions. If desired, any request handlers or middlewares can retrieve it 179 | from there in order to take further actions based on the error type. This error type also 180 | implements Axum's `OptionalFromRequestParts`, so you can more easily retrieve it using 181 | `Option` anywhere that supports extractors. 182 | 183 | ### OpenTelemetry Integration 184 | 185 | There are also optional features `tracing-otel-26` through `tracing-otel-30` 186 | that pair with the `axum-service` feature to ensure that any outgoing requests for credentials 187 | that take place in the context of an incoming web request also include the proper OpenTelemetry 188 | span information in any requests to MAudit services. Note that it is critical to use the same 189 | version of OpenTelemetry crates as the rest of the project - if you do not, there will be 2 190 | or more instances of the OpenTelemetry global information, and requests may not be traced 191 | through properly. 192 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use crate::{CLIENT, MAuthInfo}; 2 | use mauth_core::signer::Signer; 3 | use reqwest::Client; 4 | use reqwest::Url; 5 | use reqwest_middleware::ClientBuilder; 6 | use serde::Deserialize; 7 | use std::io; 8 | use thiserror::Error; 9 | use uuid::Uuid; 10 | 11 | const CONFIG_FILE: &str = ".mauth_config.yml"; 12 | 13 | impl MAuthInfo { 14 | /// Construct the MAuthInfo struct based on the contents of the config file `.mauth_config.yml` 15 | /// present in the current user's home directory. Returns an enum error type that includes the 16 | /// error types of all crates used. 17 | pub fn from_default_file() -> Result { 18 | Self::from_config_section(&Self::config_section_from_default_file()?) 19 | } 20 | 21 | pub(crate) fn config_section_from_default_file() -> Result { 22 | let mut home = dirs::home_dir().unwrap(); 23 | home.push(CONFIG_FILE); 24 | let config_data = std::fs::read_to_string(&home)?; 25 | 26 | let config_data_value: serde_yml::Value = serde_yml::from_slice(&config_data.into_bytes())?; 27 | let common_section = config_data_value 28 | .get("common") 29 | .ok_or(ConfigReadError::InvalidFile(None))?; 30 | let common_section_typed: ConfigFileSection = 31 | serde_yml::from_value(common_section.clone())?; 32 | Ok(common_section_typed) 33 | } 34 | 35 | /// Construct the MAuthInfo struct based on a passed-in ConfigFileSection instance. The 36 | /// optional input_keystore is present to support internal cloning and need not be provided 37 | /// if being used outside of the crate. 38 | pub fn from_config_section(section: &ConfigFileSection) -> Result { 39 | let full_uri: Url = format!( 40 | "{}/mauth/{}/security_tokens/", 41 | §ion.mauth_baseurl, §ion.mauth_api_version 42 | ) 43 | .parse()?; 44 | 45 | let mut pk_data = section.private_key_data.clone(); 46 | if pk_data.is_none() && section.private_key_file.is_some() { 47 | pk_data = Some(std::fs::read_to_string( 48 | section.private_key_file.as_ref().unwrap(), 49 | )?); 50 | } 51 | if pk_data.is_none() { 52 | return Err(ConfigReadError::NoPrivateKey); 53 | } 54 | 55 | let mauth_info = MAuthInfo { 56 | app_id: Uuid::parse_str(§ion.app_uuid)?, 57 | mauth_uri_base: full_uri, 58 | sign_with_v1_also: !section.v2_only_sign_requests.unwrap_or(false), 59 | allow_v1_auth: !section.v2_only_authenticate.unwrap_or(false), 60 | signer: Signer::new(section.app_uuid.clone(), pk_data.unwrap())?, 61 | }; 62 | 63 | CLIENT.get_or_init(|| { 64 | let builder = ClientBuilder::new(Client::new()).with(mauth_info.clone()); 65 | #[cfg(any( 66 | feature = "tracing-otel-26", 67 | feature = "tracing-otel-27", 68 | feature = "tracing-otel-28", 69 | feature = "tracing-otel-29", 70 | feature = "tracing-otel-30", 71 | ))] 72 | let builder = builder.with(reqwest_tracing::TracingMiddleware::default()); 73 | builder.build() 74 | }); 75 | 76 | Ok(mauth_info) 77 | } 78 | } 79 | 80 | /// All of the configuration data needed to set up a MAuthInfo struct. Implements Deserialize 81 | /// to be read from a YAML file easily, or can be created manually. 82 | #[derive(Deserialize, Clone)] 83 | pub struct ConfigFileSection { 84 | pub app_uuid: String, 85 | pub mauth_baseurl: String, 86 | pub mauth_api_version: String, 87 | pub private_key_file: Option, 88 | pub private_key_data: Option, 89 | pub v2_only_sign_requests: Option, 90 | pub v2_only_authenticate: Option, 91 | } 92 | 93 | impl Default for ConfigFileSection { 94 | fn default() -> Self { 95 | Self { 96 | app_uuid: "".to_string(), 97 | mauth_baseurl: "".to_string(), 98 | mauth_api_version: "v1".to_string(), 99 | private_key_file: None, 100 | private_key_data: None, 101 | v2_only_sign_requests: Some(true), 102 | v2_only_authenticate: Some(true), 103 | } 104 | } 105 | } 106 | 107 | /// All of the possible errors that can take place when attempting to read a config file. Errors 108 | /// are specific to the libraries that created them, and include the details from those libraries. 109 | #[derive(Debug, Error)] 110 | pub enum ConfigReadError { 111 | #[error("File Read Error: {0}")] 112 | FileReadError(#[from] io::Error), 113 | #[error("Not a valid maudit config file: {0:?}")] 114 | InvalidFile(Option), 115 | #[error("MAudit URI not valid: {0}")] 116 | InvalidUri(#[from] url::ParseError), 117 | #[error("App UUID not valid: {0}")] 118 | InvalidAppUuid(#[from] uuid::Error), 119 | #[error("Unable to parse RSA private key: {0}")] 120 | PrivateKeyDecodeError(String), 121 | #[error("Neither private_key_file nor private_key_data were provided")] 122 | NoPrivateKey, 123 | } 124 | 125 | impl From for ConfigReadError { 126 | fn from(err: mauth_core::error::Error) -> ConfigReadError { 127 | match err { 128 | mauth_core::error::Error::PrivateKeyDecodeError(pkey_err) => { 129 | ConfigReadError::PrivateKeyDecodeError(format!("{pkey_err}")) 130 | } 131 | _ => panic!("should not be possible to get this error type from signer construction"), 132 | } 133 | } 134 | } 135 | 136 | impl From for ConfigReadError { 137 | fn from(err: serde_yml::Error) -> ConfigReadError { 138 | ConfigReadError::InvalidFile(Some(err)) 139 | } 140 | } 141 | 142 | #[cfg(test)] 143 | mod test { 144 | use super::*; 145 | use tokio::fs; 146 | 147 | #[tokio::test] 148 | async fn invalid_uri_returns_right_error() { 149 | let bad_config = ConfigFileSection { 150 | app_uuid: "".to_string(), 151 | mauth_baseurl: "dfaedfaewrfaew".to_string(), 152 | mauth_api_version: "".to_string(), 153 | private_key_file: Some("".to_string()), 154 | private_key_data: None, 155 | v2_only_sign_requests: None, 156 | v2_only_authenticate: None, 157 | }; 158 | let load_result = MAuthInfo::from_config_section(&bad_config); 159 | assert!(matches!(load_result, Err(ConfigReadError::InvalidUri(_)))); 160 | } 161 | 162 | #[tokio::test] 163 | async fn bad_file_path_returns_right_error() { 164 | let bad_config = ConfigFileSection { 165 | app_uuid: "".to_string(), 166 | mauth_baseurl: "https://example.com/".to_string(), 167 | mauth_api_version: "v1".to_string(), 168 | private_key_file: Some("no_such_file".to_string()), 169 | private_key_data: None, 170 | v2_only_sign_requests: None, 171 | v2_only_authenticate: None, 172 | }; 173 | let load_result = MAuthInfo::from_config_section(&bad_config); 174 | assert!(matches!( 175 | load_result, 176 | Err(ConfigReadError::FileReadError(_)) 177 | )); 178 | } 179 | 180 | #[tokio::test] 181 | async fn bad_key_file_returns_right_error() { 182 | let filename = "dummy_file"; 183 | fs::write(&filename, b"definitely not a key").await.unwrap(); 184 | let bad_config = ConfigFileSection { 185 | app_uuid: "c7db7fde-2448-11ef-b358-125eb8485a60".to_string(), 186 | mauth_baseurl: "https://example.com/".to_string(), 187 | mauth_api_version: "v1".to_string(), 188 | private_key_file: Some(filename.to_string()), 189 | private_key_data: None, 190 | v2_only_sign_requests: None, 191 | v2_only_authenticate: None, 192 | }; 193 | let load_result = MAuthInfo::from_config_section(&bad_config); 194 | fs::remove_file(&filename).await.unwrap(); 195 | assert!(matches!( 196 | load_result, 197 | Err(ConfigReadError::PrivateKeyDecodeError(_)) 198 | )); 199 | } 200 | 201 | #[tokio::test] 202 | async fn bad_uuid_returns_right_error() { 203 | let filename = "valid_key_file"; 204 | fs::write(&filename, "invalid data").await.unwrap(); 205 | let bad_config = ConfigFileSection { 206 | app_uuid: "".to_string(), 207 | mauth_baseurl: "https://example.com/".to_string(), 208 | mauth_api_version: "v1".to_string(), 209 | private_key_file: Some(filename.to_string()), 210 | private_key_data: None, 211 | v2_only_sign_requests: None, 212 | v2_only_authenticate: None, 213 | }; 214 | let load_result = MAuthInfo::from_config_section(&bad_config); 215 | fs::remove_file(&filename).await.unwrap(); 216 | assert!(matches!( 217 | load_result, 218 | Err(ConfigReadError::InvalidAppUuid(_)) 219 | )); 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/axum_service.rs: -------------------------------------------------------------------------------- 1 | //! Structs and impls related to providing a Tower Service and Layer to verify incoming requests 2 | 3 | use axum::{ 4 | body::Body, 5 | extract::{FromRequestParts, OptionalFromRequestParts, Request}, 6 | response::IntoResponse, 7 | }; 8 | use futures_core::future::BoxFuture; 9 | use http::{Response, StatusCode, request::Parts}; 10 | use std::convert::Infallible; 11 | use std::error::Error; 12 | use std::task::{Context, Poll}; 13 | use tower::{Layer, Service}; 14 | use tracing::error; 15 | 16 | use crate::validate_incoming::{MAuthValidationError, ValidatedRequestDetails}; 17 | use crate::{ 18 | MAuthInfo, 19 | config::{ConfigFileSection, ConfigReadError}, 20 | }; 21 | 22 | /// This is a Tower Service which validates that incoming requests have a valid 23 | /// MAuth signature. It only passes the request down to the next layer if the 24 | /// signature is valid, otherwise it returns an appropriate error to the caller. 25 | pub struct RequiredMAuthValidationService { 26 | mauth_info: MAuthInfo, 27 | config_info: ConfigFileSection, 28 | service: S, 29 | } 30 | 31 | impl Service for RequiredMAuthValidationService 32 | where 33 | S: Service + Send + Clone + 'static, 34 | S::Future: Send + 'static, 35 | S::Error: Into>, 36 | S::Response: Into>, 37 | { 38 | type Response = Response; 39 | type Error = S::Error; 40 | type Future = BoxFuture<'static, Result>; 41 | 42 | fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { 43 | self.service.poll_ready(cx) 44 | } 45 | 46 | fn call(&mut self, request: Request) -> Self::Future { 47 | let mut cloned = self.clone(); 48 | Box::pin(async move { 49 | match cloned.mauth_info.validate_request(request).await { 50 | Ok(valid_request) => match cloned.service.call(valid_request).await { 51 | Ok(response) => Ok(response.into()), 52 | Err(err) => Err(err), 53 | }, 54 | Err(err) => { 55 | error!( 56 | error = ?err, 57 | "Failed to validate MAuth signature, rejecting request" 58 | ); 59 | Ok(StatusCode::UNAUTHORIZED.into_response()) 60 | } 61 | } 62 | }) 63 | } 64 | } 65 | 66 | impl Clone for RequiredMAuthValidationService { 67 | fn clone(&self) -> Self { 68 | RequiredMAuthValidationService { 69 | // unwrap is safe because we validated the config_info before constructing the layer 70 | mauth_info: MAuthInfo::from_config_section(&self.config_info).unwrap(), 71 | config_info: self.config_info.clone(), 72 | service: self.service.clone(), 73 | } 74 | } 75 | } 76 | 77 | /// This is a Tower Layer which applies the RequiredMAuthValidationService on top of the 78 | /// service provided to it. 79 | #[derive(Clone)] 80 | pub struct RequiredMAuthValidationLayer { 81 | config_info: ConfigFileSection, 82 | } 83 | 84 | impl Layer for RequiredMAuthValidationLayer { 85 | type Service = RequiredMAuthValidationService; 86 | 87 | fn layer(&self, service: S) -> Self::Service { 88 | RequiredMAuthValidationService { 89 | // unwrap is safe because we validated the config_info before constructing the layer 90 | mauth_info: MAuthInfo::from_config_section(&self.config_info).unwrap(), 91 | config_info: self.config_info.clone(), 92 | service, 93 | } 94 | } 95 | } 96 | 97 | impl RequiredMAuthValidationLayer { 98 | /// Construct a RequiredMAuthValidationLayer based on the configuration options in the file 99 | /// found in the default location. 100 | pub fn from_default_file() -> Result { 101 | let config_info = MAuthInfo::config_section_from_default_file()?; 102 | // Generate a MAuthInfo and then drop it to validate that it works, 103 | // making it safe to use `unwrap` in the service constructor. 104 | MAuthInfo::from_config_section(&config_info)?; 105 | Ok(RequiredMAuthValidationLayer { config_info }) 106 | } 107 | 108 | /// Construct a RequiredMAuthValidationLayer based on the configuration options in a manually 109 | /// created or parsed ConfigFileSection. 110 | pub fn from_config_section(config_info: ConfigFileSection) -> Result { 111 | MAuthInfo::from_config_section(&config_info)?; 112 | Ok(RequiredMAuthValidationLayer { config_info }) 113 | } 114 | } 115 | 116 | /// This is a Tower Service which validates that incoming requests have a valid 117 | /// MAuth signature. Unlike the Required service, if this service is not able to 118 | /// find or validate a signature, it passes the request down to the lower layers 119 | /// anyways. This means that it is the responsibility of the request handler to 120 | /// check for the `ValidatedRequestDetails` extension to determine if the request 121 | /// has a valid signature. It also means that this service is safe to attach to 122 | /// the whole application, even if some requests are not validated at all or may 123 | /// be validated in a different way. 124 | pub struct OptionalMAuthValidationService { 125 | mauth_info: MAuthInfo, 126 | config_info: ConfigFileSection, 127 | service: S, 128 | } 129 | 130 | impl Service for OptionalMAuthValidationService 131 | where 132 | S: Service + Send + Clone + 'static, 133 | S::Future: Send + 'static, 134 | S::Error: Into>, 135 | { 136 | type Response = S::Response; 137 | type Error = S::Error; 138 | type Future = BoxFuture<'static, Result>; 139 | 140 | fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { 141 | self.service.poll_ready(cx) 142 | } 143 | 144 | fn call(&mut self, request: Request) -> Self::Future { 145 | let mut cloned = self.clone(); 146 | Box::pin(async move { 147 | let processed_request = cloned.mauth_info.validate_request_optionally(request).await; 148 | cloned.service.call(processed_request).await 149 | }) 150 | } 151 | } 152 | 153 | impl Clone for OptionalMAuthValidationService { 154 | fn clone(&self) -> Self { 155 | OptionalMAuthValidationService { 156 | // unwrap is safe because we validated the config_info before constructing the layer 157 | mauth_info: MAuthInfo::from_config_section(&self.config_info).unwrap(), 158 | config_info: self.config_info.clone(), 159 | service: self.service.clone(), 160 | } 161 | } 162 | } 163 | 164 | /// This is a Tower Layer which applies the OptionalMAuthValidationService on top of the 165 | /// service provided to it. 166 | #[derive(Clone)] 167 | pub struct OptionalMAuthValidationLayer { 168 | config_info: ConfigFileSection, 169 | } 170 | 171 | impl Layer for OptionalMAuthValidationLayer { 172 | type Service = OptionalMAuthValidationService; 173 | 174 | fn layer(&self, service: S) -> Self::Service { 175 | OptionalMAuthValidationService { 176 | // unwrap is safe because we validated the config_info before constructing the layer 177 | mauth_info: MAuthInfo::from_config_section(&self.config_info).unwrap(), 178 | config_info: self.config_info.clone(), 179 | service, 180 | } 181 | } 182 | } 183 | 184 | impl OptionalMAuthValidationLayer { 185 | /// Construct an OptionalMAuthValidationLayer based on the configuration options in the file 186 | /// found in the default location. 187 | pub fn from_default_file() -> Result { 188 | let config_info = MAuthInfo::config_section_from_default_file()?; 189 | // Generate a MAuthInfo and then drop it to validate that it works, 190 | // making it safe to use `unwrap` in the service constructor. 191 | MAuthInfo::from_config_section(&config_info)?; 192 | Ok(OptionalMAuthValidationLayer { config_info }) 193 | } 194 | 195 | /// Construct an OptionalMAuthValidationLayer based on the configuration options in a manually 196 | /// created or parsed ConfigFileSection. 197 | pub fn from_config_section(config_info: ConfigFileSection) -> Result { 198 | MAuthInfo::from_config_section(&config_info)?; 199 | Ok(OptionalMAuthValidationLayer { config_info }) 200 | } 201 | } 202 | 203 | impl FromRequestParts for ValidatedRequestDetails 204 | where 205 | S: Send + Sync, 206 | { 207 | type Rejection = StatusCode; 208 | 209 | async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { 210 | parts 211 | .extensions 212 | .get::() 213 | .cloned() 214 | .ok_or(StatusCode::UNAUTHORIZED) 215 | } 216 | } 217 | 218 | impl OptionalFromRequestParts for ValidatedRequestDetails 219 | where 220 | S: Send + Sync, 221 | { 222 | type Rejection = Infallible; 223 | 224 | async fn from_request_parts( 225 | parts: &mut Parts, 226 | _state: &S, 227 | ) -> Result, Self::Rejection> { 228 | Ok(parts.extensions.get::().cloned()) 229 | } 230 | } 231 | 232 | impl OptionalFromRequestParts for MAuthValidationError 233 | where 234 | S: Send + Sync, 235 | { 236 | type Rejection = Infallible; 237 | 238 | async fn from_request_parts( 239 | parts: &mut Parts, 240 | _state: &S, 241 | ) -> Result, Self::Rejection> { 242 | Ok(parts.extensions.get::().cloned()) 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /src/validate_incoming.rs: -------------------------------------------------------------------------------- 1 | use crate::{CLIENT, MAuthInfo, PUBKEY_CACHE}; 2 | use axum::extract::Request; 3 | use bytes::Bytes; 4 | use chrono::prelude::*; 5 | use mauth_core::verifier::Verifier; 6 | use thiserror::Error; 7 | use tracing::error; 8 | use uuid::Uuid; 9 | 10 | /// This struct holds the app UUID for a validated request. It is meant to be used with the 11 | /// Extension setup in Hyper requests, where it is placed in requests that passed authentication. 12 | /// The custom struct makes it clearer that the request has passed and this is an authenticated 13 | /// app UUID and not some random UUID that some other component put in place for some other 14 | /// purpose. 15 | #[derive(Debug, Clone)] 16 | #[non_exhaustive] 17 | pub struct ValidatedRequestDetails { 18 | pub app_uuid: Uuid, 19 | } 20 | 21 | const MAUTH_V1_SIGNATURE_HEADER: &str = "X-MWS-Authentication"; 22 | const MAUTH_V2_SIGNATURE_HEADER: &str = "MCC-Authentication"; 23 | const MAUTH_V1_TIMESTAMP_HEADER: &str = "X-MWS-Time"; 24 | const MAUTH_V2_TIMESTAMP_HEADER: &str = "MCC-Time"; 25 | 26 | impl MAuthInfo { 27 | pub(crate) async fn validate_request( 28 | &self, 29 | req: Request, 30 | ) -> Result { 31 | let (mut parts, body) = req.into_parts(); 32 | let body_bytes = axum::body::to_bytes(body, usize::MAX) 33 | .await 34 | .map_err(|_| MAuthValidationError::InvalidBody)?; 35 | match self.validate_request_v2(&parts, &body_bytes).await { 36 | Ok(host_app_uuid) => { 37 | parts.extensions.insert(ValidatedRequestDetails { 38 | app_uuid: host_app_uuid, 39 | }); 40 | let new_body = axum::body::Body::from(body_bytes); 41 | let new_request = Request::from_parts(parts, new_body); 42 | Ok(new_request) 43 | } 44 | Err(err) => { 45 | if self.allow_v1_auth { 46 | match self.validate_request_v1(&parts, &body_bytes).await { 47 | Ok(host_app_uuid) => { 48 | parts.extensions.insert(ValidatedRequestDetails { 49 | app_uuid: host_app_uuid, 50 | }); 51 | let new_body = axum::body::Body::from(body_bytes); 52 | let new_request = Request::from_parts(parts, new_body); 53 | Ok(new_request) 54 | } 55 | Err(err) => Err(err), 56 | } 57 | } else { 58 | Err(err) 59 | } 60 | } 61 | } 62 | } 63 | 64 | pub(crate) async fn validate_request_optionally(&self, req: Request) -> Request { 65 | let (mut parts, body) = req.into_parts(); 66 | if parts.headers.contains_key(MAUTH_V2_SIGNATURE_HEADER) 67 | || parts.headers.contains_key(MAUTH_V1_SIGNATURE_HEADER) 68 | { 69 | // By my reading of the code for this it should never fail, since we are passing 70 | // MAX for the limit. But just to be safe, we will log the error and proceed with 71 | // an empty body just in case instead of unwrapping. This would cause the body to 72 | // be unavailable to the lower layers, but they would probably also fail to get it 73 | // anyways since we just did here. 74 | let body_bytes = match axum::body::to_bytes(body, usize::MAX).await { 75 | Ok(bytes) => bytes, 76 | Err(error) => { 77 | error!( 78 | ?error, 79 | "Failed to retrieve request body, continuing with empty body" 80 | ); 81 | Bytes::new() 82 | } 83 | }; 84 | 85 | match self.validate_request_v2(&parts, &body_bytes).await { 86 | Ok(host_app_uuid) => { 87 | parts.extensions.insert(ValidatedRequestDetails { 88 | app_uuid: host_app_uuid, 89 | }); 90 | } 91 | Err(error_v2) => { 92 | if self.allow_v1_auth { 93 | match self.validate_request_v1(&parts, &body_bytes).await { 94 | Ok(host_app_uuid) => { 95 | parts.extensions.insert(ValidatedRequestDetails { 96 | app_uuid: host_app_uuid, 97 | }); 98 | } 99 | Err(error_v1) => { 100 | error!( 101 | ?error_v2, 102 | ?error_v1, 103 | "Error attempting to validate MAuth signatures" 104 | ); 105 | parts.extensions.insert(error_v1); 106 | } 107 | } 108 | } else { 109 | error!(?error_v2, "Error attempting to validate MAuth V2 signature"); 110 | parts.extensions.insert(error_v2); 111 | } 112 | } 113 | } 114 | 115 | let new_body = axum::body::Body::from(body_bytes); 116 | Request::from_parts(parts, new_body) 117 | } else { 118 | Request::from_parts(parts, body) 119 | } 120 | } 121 | 122 | async fn validate_request_v2( 123 | &self, 124 | req: &http::request::Parts, 125 | body_bytes: &bytes::Bytes, 126 | ) -> Result { 127 | //retrieve and parse auth string 128 | let sig_header = req 129 | .headers 130 | .get(MAUTH_V2_SIGNATURE_HEADER) 131 | .ok_or(MAuthValidationError::NoSig)? 132 | .to_str() 133 | .map_err(|_| MAuthValidationError::InvalidSignature)?; 134 | let (host_app_uuid, raw_signature) = Self::split_auth_string(sig_header, "MWSV2")?; 135 | 136 | //retrieve and validate timestamp 137 | let ts_str = req 138 | .headers 139 | .get(MAUTH_V2_TIMESTAMP_HEADER) 140 | .ok_or(MAuthValidationError::NoTime)? 141 | .to_str() 142 | .map_err(|_| MAuthValidationError::InvalidTime)?; 143 | Self::validate_timestamp(ts_str)?; 144 | 145 | match self.get_app_pub_key(&host_app_uuid).await { 146 | None => Err(MAuthValidationError::KeyUnavailable), 147 | Some(verifier) => { 148 | if let Ok(signature) = String::from_utf8(raw_signature) { 149 | match verifier.verify_signature( 150 | 2, 151 | req.method.as_str(), 152 | req.uri.path(), 153 | req.uri.query().unwrap_or(""), 154 | body_bytes, 155 | ts_str, 156 | signature, 157 | ) { 158 | Ok(()) => Ok(host_app_uuid), 159 | Err(_) => Err(MAuthValidationError::SignatureVerifyFailure), 160 | } 161 | } else { 162 | Err(MAuthValidationError::SignatureVerifyFailure) 163 | } 164 | } 165 | } 166 | } 167 | 168 | async fn validate_request_v1( 169 | &self, 170 | req: &http::request::Parts, 171 | body_bytes: &bytes::Bytes, 172 | ) -> Result { 173 | //retrieve and parse auth string 174 | let sig_header = req 175 | .headers 176 | .get(MAUTH_V1_SIGNATURE_HEADER) 177 | .ok_or(MAuthValidationError::NoSig)? 178 | .to_str() 179 | .map_err(|_| MAuthValidationError::InvalidSignature)?; 180 | let (host_app_uuid, raw_signature) = Self::split_auth_string(sig_header, "MWS")?; 181 | 182 | //retrieve and validate timestamp 183 | let ts_str = req 184 | .headers 185 | .get(MAUTH_V1_TIMESTAMP_HEADER) 186 | .ok_or(MAuthValidationError::NoTime)? 187 | .to_str() 188 | .map_err(|_| MAuthValidationError::InvalidTime)?; 189 | Self::validate_timestamp(ts_str)?; 190 | 191 | match self.get_app_pub_key(&host_app_uuid).await { 192 | None => Err(MAuthValidationError::KeyUnavailable), 193 | Some(verifier) => { 194 | if let Ok(signature) = String::from_utf8(raw_signature) { 195 | match verifier.verify_signature( 196 | 1, 197 | req.method.as_str(), 198 | req.uri.path(), 199 | req.uri.query().unwrap_or(""), 200 | body_bytes, 201 | ts_str, 202 | signature, 203 | ) { 204 | Ok(()) => Ok(host_app_uuid), 205 | Err(_) => Err(MAuthValidationError::SignatureVerifyFailure), 206 | } 207 | } else { 208 | Err(MAuthValidationError::SignatureVerifyFailure) 209 | } 210 | } 211 | } 212 | } 213 | 214 | fn validate_timestamp(timestamp_str: &str) -> Result<(), MAuthValidationError> { 215 | let ts_num: i64 = timestamp_str 216 | .parse() 217 | .map_err(|_| MAuthValidationError::InvalidTime)?; 218 | let ts_diff = ts_num - Utc::now().timestamp(); 219 | if !(-300..=300).contains(&ts_diff) { 220 | Err(MAuthValidationError::InvalidTime) 221 | } else { 222 | Ok(()) 223 | } 224 | } 225 | 226 | fn split_auth_string( 227 | auth_str: &str, 228 | expected_prefix: &str, 229 | ) -> Result<(Uuid, Vec), MAuthValidationError> { 230 | let header_pattern = vec![' ', ':', ';']; 231 | let mut header_split = auth_str.split(header_pattern.as_slice()); 232 | 233 | let start_str = header_split 234 | .next() 235 | .ok_or(MAuthValidationError::InvalidSignature)?; 236 | if start_str != expected_prefix { 237 | return Err(MAuthValidationError::InvalidSignature); 238 | } 239 | let host_uuid_str = header_split 240 | .next() 241 | .ok_or(MAuthValidationError::InvalidSignature)?; 242 | let host_app_uuid = 243 | Uuid::parse_str(host_uuid_str).map_err(|_| MAuthValidationError::InvalidSignature)?; 244 | let signature_encoded_string = header_split 245 | .next() 246 | .ok_or(MAuthValidationError::InvalidSignature)?; 247 | Ok((host_app_uuid, signature_encoded_string.into())) 248 | } 249 | 250 | async fn get_app_pub_key(&self, app_uuid: &Uuid) -> Option { 251 | { 252 | let key_store = PUBKEY_CACHE.read().unwrap(); 253 | if let Some(pub_key) = key_store.get(app_uuid) { 254 | return Some(pub_key.clone()); 255 | } 256 | } 257 | let uri = self.mauth_uri_base.join(&format!("{}", &app_uuid)).unwrap(); 258 | let mauth_response = CLIENT.get().unwrap().get(uri).send().await; 259 | match mauth_response { 260 | Err(_) => None, 261 | Ok(response) => { 262 | if let Ok(response_obj) = response.json::().await 263 | && let Some(pub_key_str) = response_obj 264 | .pointer("/security_token/public_key_str") 265 | .and_then(|s| s.as_str()) 266 | .map(|st| st.to_owned()) 267 | && let Ok(verifier) = Verifier::new(*app_uuid, pub_key_str) 268 | { 269 | let mut key_store = PUBKEY_CACHE.write().unwrap(); 270 | key_store.insert(*app_uuid, verifier.clone()); 271 | Some(verifier) 272 | } else { 273 | None 274 | } 275 | } 276 | } 277 | } 278 | } 279 | 280 | /// All of the possible errors that can take place when attempting to verify a response signature 281 | #[derive(Debug, Error, Clone)] 282 | pub enum MAuthValidationError { 283 | /// The timestamp of the response was either invalid or outside of the permitted 284 | /// range 285 | #[error("The timestamp of the response was either invalid or outside of the permitted range")] 286 | InvalidTime, 287 | /// The MAuth signature of the response was either missing or incorrectly formatted 288 | #[error("The MAuth signature of the response was either missing or incorrectly formatted")] 289 | InvalidSignature, 290 | /// The timestamp header of the response was missing 291 | #[error("The timestamp header of the response was missing")] 292 | NoTime, 293 | /// The signature header of the response was missing 294 | #[error("The signature header of the response was missing")] 295 | NoSig, 296 | /// An error occurred while attempting to retrieve part of the response body 297 | #[error("An error occurred while attempting to retrieve part of the response body")] 298 | ResponseProblem, 299 | /// The response body failed to parse 300 | #[error("The response body failed to parse")] 301 | InvalidBody, 302 | /// Attempt to retrieve a key to verify the response failed 303 | #[error("Attempt to retrieve a key to verify the response failed")] 304 | KeyUnavailable, 305 | /// The body of the response did not match the signature 306 | #[error("The body of the response did not match the signature")] 307 | SignatureVerifyFailure, 308 | } 309 | --------------------------------------------------------------------------------