From c88f80bcc96a2fda5943391b27a431ab9a2c71e2 Mon Sep 17 00:00:00 2001 From: Rui Barbosa Date: Mon, 4 Sep 2023 18:20:00 -0400 Subject: [PATCH] Jwt (#12) * oatuh jwt * oatuh jwt * jwt basic * jwt assertion * jwt token * jwt token * added JWT_ENV --- .github/workflows/ci_orig.yml | 5 + Cargo.toml | 3 + examples/auth_jwt.rs | 56 ++++++++ src/auth.rs | 1 + src/auth/auth_errors.rs | 18 +++ src/auth/auth_jwt.rs | 205 +++++++++++++++++++++++++++ src/auth/unit_tests/auth_jwt_test.rs | 57 ++++++++ tests/keys/private.pem | 30 ++++ tests/keys/public.pem | 9 ++ 9 files changed, 384 insertions(+) create mode 100644 examples/auth_jwt.rs create mode 100644 src/auth/auth_jwt.rs create mode 100644 src/auth/unit_tests/auth_jwt_test.rs create mode 100644 tests/keys/private.pem create mode 100644 tests/keys/public.pem diff --git a/.github/workflows/ci_orig.yml b/.github/workflows/ci_orig.yml index ecc5f77..c6ad8ca 100644 --- a/.github/workflows/ci_orig.yml +++ b/.github/workflows/ci_orig.yml @@ -79,6 +79,11 @@ jobs: run: | echo "${{secrets.CCG_ENV}}" echo "${{secrets.CCG_ENV}}" | base64 -d > .ccg.env + + - name: Create .jwt.env from secrets + run: | + echo "${{secrets.JWT_ENV}}" + echo "${{secrets.JWT_ENV}}" | base64 -d > .jwt.env - name: Build sources uses: actions-rs/cargo@v1 diff --git a/Cargo.toml b/Cargo.toml index 10799b6..b13427a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,9 @@ chrono = { version = "0.4.24", features = ["serde"] } async-trait = "0.1.68" url = "2.2.2" rand = "0.8.5" +josekit = "0.8.3" +openssl = "0.10.57" +uuid = { version = "1.4.1", features = ["v4", "fast-rng","macro-diagnostics"]} [dev-dependencies] pretty_assertions = "1.3.0" diff --git a/examples/auth_jwt.rs b/examples/auth_jwt.rs new file mode 100644 index 0000000..f242695 --- /dev/null +++ b/examples/auth_jwt.rs @@ -0,0 +1,56 @@ +// use cargo run --example users_main to run this file +// use dotenv; + +use rusty_box::{ + auth::auth_jwt::JWTAuth, + auth::auth_jwt::SubjectType, + client::{box_client::BoxClient, client_error::BoxAPIError}, + config::Config, + rest_api::users::users_api, +}; +use std::env; + +#[tokio::main] +async fn main() -> Result<(), BoxAPIError> { + dotenv::from_filename(".jwt.env").ok(); + + let client_id = env::var("CLIENT_ID").expect("CLIENT_ID must be set"); + let client_secret = env::var("CLIENT_SECRET").expect("CLIENT_SECRET must be set"); + + let env_subject_type = env::var("BOX_SUBJECT_TYPE").expect("BOX_SUBJECT_TYPE must be set"); + let box_subject_type = match env_subject_type.as_str() { + "user" => SubjectType::User, + "enterprise" => SubjectType::Enterprise, + _ => panic!("BOX_SUBJECT_TYPE must be either 'user' or 'enterprise'"), + }; + + let box_subject_id = env::var("BOX_SUBJECT_ID").expect("BOX_SUBJECT_ID must be set"); + + let public_key_id = env::var("PUBLIC_KEY_ID").expect("PUBLIC_KEY_ID must be set"); + + let encrypted_private_key = + env::var("ENCRYPTED_PRIVATE_KEY").expect("ENCRYPTED_PRIVATE_KEY must be set"); + + let passphrase = env::var("PASSPHRASE").expect("PASSPHRASE must be set"); + + let config = Config::new(); + let auth = JWTAuth::new( + config, + client_id, + client_secret, + box_subject_type, + box_subject_id, + public_key_id, + encrypted_private_key, + passphrase, + ); + + let mut client = BoxClient::new(Box::new(auth)); + + let fields = vec![]; + + let me = users_api::me(&mut client, Some(fields)).await?; + println!("Me:\n{me:#?}\n"); + + Ok(()) +} diff --git a/src/auth.rs b/src/auth.rs index 507796d..fb07238 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -4,6 +4,7 @@ pub mod auth_ccg; pub mod auth_client; pub mod auth_developer; pub mod auth_errors; +pub mod auth_jwt; pub mod auth_oauth; use async_trait::async_trait; diff --git a/src/auth/auth_errors.rs b/src/auth/auth_errors.rs index a85024b..f12aefb 100644 --- a/src/auth/auth_errors.rs +++ b/src/auth/auth_errors.rs @@ -38,6 +38,8 @@ pub enum AuthError { Io(std::io::Error), Generic(String), ResponseError(AuthErrorResponse), + JoseError(josekit::JoseError), + OpenSSl(openssl::error::ErrorStack), } impl fmt::Display for AuthError { @@ -48,6 +50,8 @@ impl fmt::Display for AuthError { AuthError::Io(e) => ("IO", e.to_string()), AuthError::Generic(e) => ("Token", e.to_string()), AuthError::ResponseError(e) => ("API Error", e.to_string()), + AuthError::JoseError(e) => ("Jose", e.to_string()), + AuthError::OpenSSl(e) => ("OpenSSL", e.to_string()), }; write!(f, "error in {}: {}", module, e) } @@ -61,6 +65,8 @@ impl fmt::Debug for AuthError { AuthError::Io(e) => ("IO", e.to_string()), AuthError::Generic(e) => ("Token", e.to_string()), AuthError::ResponseError(e) => ("API Error", e.to_string()), + AuthError::JoseError(e) => ("Jose", e.to_string()), + AuthError::OpenSSl(e) => ("OpenSSL", e.to_string()), }; write!(f, "error in {}: {}", module, e) } @@ -95,3 +101,15 @@ impl From for AuthError { AuthError::ResponseError(e) } } + +impl From for AuthError { + fn from(e: josekit::JoseError) -> Self { + AuthError::JoseError(e) + } +} + +impl From for AuthError { + fn from(e: openssl::error::ErrorStack) -> Self { + AuthError::OpenSSl(e) + } +} diff --git a/src/auth/auth_jwt.rs b/src/auth/auth_jwt.rs new file mode 100644 index 0000000..c2a2c31 --- /dev/null +++ b/src/auth/auth_jwt.rs @@ -0,0 +1,205 @@ +//! JSON Web Token (JWT) authentication +use super::access_token::AccessToken; +use super::auth_client::{AuthClient, Form}; +use super::{Auth, AuthError}; +use crate::config::Config; + +use async_trait::async_trait; +use chrono::{DateTime, Duration, Utc}; +use josekit::{ + jws::RS512, + jwt::{self, JwtPayload}, +}; +use openssl::pkey::PKey; +use serde::Serialize; +use serde_json::json; + +/// The type of subject that is being authenticated (user or enterprise) +#[derive(Debug, Clone, Serialize, PartialEq)] +pub enum SubjectType { + Enterprise, + User, +} +impl SubjectType { + fn value(&self) -> String { + match self { + Self::Enterprise => "enterprise".to_owned(), + Self::User => "user".to_owned(), + } + } +} +impl Default for SubjectType { + fn default() -> SubjectType { + Self::Enterprise + } +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct JWTAuth { + pub config: Config, + client_id: String, + client_secret: String, + + box_subject_type: SubjectType, + box_subject_id: String, + + public_key_id: String, + #[serde(skip)] + private_key: String, + #[serde(skip)] + passphrase: String, + access_token: AccessToken, + expires_by: DateTime, + #[serde(skip)] + client: AuthClient, +} + +impl JWTAuth { + #[allow(clippy::too_many_arguments)] + pub fn new( + config: Config, + client_id: String, + client_secret: String, + + box_subject_type: SubjectType, + box_subject_id: String, + + public_key_id: String, + private_key: String, + passphrase: String, + ) -> Self { + JWTAuth { + config, + client_id, + client_secret, + + box_subject_type, + box_subject_id, + + public_key_id, + private_key, + passphrase, + + access_token: AccessToken::new(), + expires_by: Utc::now(), + client: AuthClient::default(), + } + } + + pub fn is_expired(&self) -> bool { + Utc::now() > self.expires_by - Duration::seconds(60 * 5) + } + + async fn fetch_access_token(&mut self) -> Result { + let url = &(self.config.oauth2_api_url.clone() + "/token"); + + let headers = None; // TODO: Add headers to rquest + + let jwt_token = jwt_assertion(self.clone())?; + + let mut payload = Form::new(); + payload.insert("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"); + payload.insert("client_id", &self.client_id); + payload.insert("client_secret", &self.client_secret); + + payload.insert("assertion", &jwt_token); + + let now = Utc::now(); + + let response = self.client.post_form(url, headers, &payload).await; + + let data = match response { + Ok(data) => data, + Err(e) => return Err(e), + }; + + let access_token = match serde_json::from_str::(&data) { + Ok(access_token) => access_token, + Err(e) => { + return Err(AuthError::Serde(e)); + } + }; + let expires_in = access_token.expires_in.unwrap_or_default(); + self.expires_by = now + Duration::seconds(expires_in); + self.access_token = access_token.clone(); + Ok(access_token) + } +} + +fn jwt_assertion(jwt_aut: JWTAuth) -> Result { + // JWT Header + let mut header = josekit::jws::JwsHeader::new(); + header.set_token_type("JWT"); + + header.set_key_id(jwt_aut.public_key_id); + + // JWT Payload + let mut payload = JwtPayload::new(); + + payload.set_issuer(jwt_aut.client_id); + + payload.set_subject(jwt_aut.box_subject_id); + + let box_subject_type = Some(json!(jwt_aut.box_subject_type.value())); + payload.set_claim("box_sub_type", box_subject_type)?; + + let audience = vec![jwt_aut.config.oauth2_api_url + "/token"]; + payload.set_audience(audience); + + let jwt_id = uuid::Uuid::new_v4().to_string(); + payload.set_jwt_id(jwt_id); + + let expires_at = std::time::SystemTime::now() + std::time::Duration::from_secs(59); + payload.set_expires_at(&expires_at); + + // decrupt private key + let private_key = PKey::private_key_from_pem_passphrase( + jwt_aut.private_key.as_bytes(), + jwt_aut.passphrase.as_bytes(), + )?; + + let private_key_pem = private_key.private_key_to_pem_pkcs8()?; + + let signer = RS512.signer_from_pem(private_key_pem)?; + let jwt = jwt::encode_with_signer(&payload, &header, &signer)?; + + Ok(jwt) +} + +#[async_trait] +impl<'a> Auth<'a> for JWTAuth { + async fn access_token(&mut self) -> Result { + if self.is_expired() { + match self.fetch_access_token().await { + Ok(access_token) => Ok(access_token.access_token.unwrap_or_default()), + Err(e) => Err(e), + } + } else { + let access_token = match self.access_token.access_token.clone() { + Some(token) => token, + None => return Err(AuthError::Generic("CCG token is not set".to_owned())), + }; + Ok(access_token) + } + } + + async fn to_json(&mut self) -> Result { + self.access_token().await?; + match serde_json::to_string(&self) { + Ok(json) => Ok(json), + Err(e) => Err(AuthError::Serde(e)), + } + } + + fn base_api_url(&self) -> String { + self.config.base_api_url() + } + + fn user_agent(&self) -> String { + self.config.user_agent() + } +} + +#[cfg(test)] +#[path = "./unit_tests/auth_jwt_test.rs"] +mod auth_jwt_test; diff --git a/src/auth/unit_tests/auth_jwt_test.rs b/src/auth/unit_tests/auth_jwt_test.rs new file mode 100644 index 0000000..b0e708e --- /dev/null +++ b/src/auth/unit_tests/auth_jwt_test.rs @@ -0,0 +1,57 @@ +use crate::config; + +use super::*; + +#[test] +fn test_jwt_assertion() { + // should create a Box JWT assertion + let config = config::Config::default(); + + // print current dir + println!("Current dir: {:?}", std::env::current_dir()); + + let encrypted_private_key = std::fs::read_to_string("tests/keys/private.pem").unwrap(); + let public_key = std::fs::read_to_string("tests/keys/public.pem").unwrap(); + let passphrase = "ABCD".to_string(); + + let jwt_auth = JWTAuth::new( + config, + "client_id".to_string(), + "client_secret".to_string(), + SubjectType::Enterprise, + "box_subject_id".to_string(), + "public_key_id".to_string(), + encrypted_private_key, + passphrase, + ); + + let jwt = jwt_assertion(jwt_auth).unwrap_or("".to_string()); + + // have a non empty jwt + assert!(jwt.len() > 0); + + //have 3 dots in assertion + let count = jwt.chars().filter(|&c| c == '.').count(); + assert_eq!(count, 2); + + // have a valid signature + // Verifing JWT + let verifier = RS512.verifier_from_pem(&public_key).unwrap(); + let (payload, header) = jwt::decode_with_verifier(&jwt, &verifier).unwrap(); + + // assert headers + assert_eq!(header.token_type(), Some("JWT")); + assert_eq!(header.key_id(), Some("public_key_id")); + assert_eq!(header.algorithm(), Some("RS512")); + + //assert claims + assert_eq!(payload.issuer(), Some("client_id")); + assert_eq!(payload.subject(), Some("box_subject_id")); + assert_eq!(payload.claim("box_sub_type"), Some(&json!("enterprise"))); + assert_eq!( + payload.audience(), + Some(vec!["https://api.box.com/oauth2/token"]) + ); + assert!(payload.jwt_id() != Some("")); + assert!(payload.expires_at() > Some(std::time::SystemTime::now())); +} diff --git a/tests/keys/private.pem b/tests/keys/private.pem new file mode 100644 index 0000000..ea4c099 --- /dev/null +++ b/tests/keys/private.pem @@ -0,0 +1,30 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIFHDBOBgkqhkiG9w0BBQ0wQTApBgkqhkiG9w0BBQwwHAQIaplZnU83OQgCAggA +MAwGCCqGSIb3DQIJBQAwFAYIKoZIhvcNAwcECILC0CaOrIl+BIIEyImt9feBb5r+ +Th+eBwNoek9hjU6GdWBb2TACGRc3zAIHj/GhEu8b4wlfNBuQr1lbHhBI+ci0gnEk +295lIuKXfScNzM6BPJwbsTLseBZ1YQHJO8BnwC9qaSikMgMPsY1odM1UxMUNPS+t +SCKd3wtUQeMxBmP+v8DtmIf/5I/MPyNw+BINVSlHV+Md28dL/eCkEXCGooFpUv6E +WtcV7rCNosxuong7ao5T/jymkMR6bHWN5m5Dmc1AhQQsrX2err8WSrCSXaG+EWB7 +5Uda++PvQ0VUeXY72ZoeS7boaGd93JOYllHZvqevJYQKAmMJnRz9gZIrC8F9NqBi +8FxP+xjRhanpKjnQfPORi5FkBzo67XeIAZIjttjEKkyKRMyKvY7TW9mAVn2IIpYA +6DlmIGmMIEJJBn7EfVQ/Gs1A6U/Db6+ZFBWeQy7mRaVTNfAkev8xzTioSB8TAz8I +sMUQVhn8CwnCThK13sYvb1PvMuVPaq26iHmDq3T8IGlVcj2Xs2YZgP6cUwlMLimm +Vu6bqKXqTUbZ0gdDNxJYjPhJ3z57O6a118HD2jfJYKKYD+ApBVvSXQ/pdcLP60bQ +fLFCVaYJ24DoHRN7zfW/8C9y+Smd8CcGAEIThV4F3TIPvpjV0OBZIriZWBW2xqRv +Utf34DA8bglWyQhYOnYKdzm1Awm5GaAO0pRRaCSixdG3++bMmcMd+xlOWnug8Dq8 +g78w43i6fKd33IEoNzJqRsVp2Tr5mDdE8nvu607sgDp/i/hRg/FcKoXdmNwGPduz +tAJPTi9Xw/mH4BJgT/vDlQh8ZZ8JRQGCwHB0s9Kmj6RHJ+0Nmw76c6drOpZMsWfG +6fFt5Ncj6v3PKEzY8IxHYPBIhWRwg/HF8NenEV9KIvmGe2L+lk7k2wWpTvvIukBo +wrddz74LyJsp/5xP4JFACa5GpGOJapdMou+kbu4rM8QH1h+nRrPVmy8hWClLSULw +AqUh33J48pZOLGkHQtM+5X+xDa5b4BfND//7ofBpUQ+n/xnUFan19SXYynMrHN9f +CqkF7KU4X6On0NBH+jYNXTSjWCXooBHpeOwCZICFJKHyD3z7L902NvJ+KiZFmYaS +Fgbo/+qPHa4+A0G8Jzg0SyZsGc9wyByP2cpbuuow9rJ2GSNBQIHcv7KmsFGqCUwv +z6aOIPK03lmLHrbW+FD+oTLOvXVcqtuL85kPhmJCd6SRM/ouLLRL5Wp5DwtEVmsn +bLvDcDfssf6ma2uBSluahSq8pV4ZClZ/+fw3cQoC91Wl473zaDJ1AhHhNurpKqhc +gkvf267BuiJPxO2nRSWp+rMm4PgmUkqNxRqIUoaQ4+qlE8sFz7Xphw/FFf1vmaEX +mZTfW1BJ6LOlHYjp3R1sMpXRidbQZ74I2NaPjrsHpFo2AtWWg3JW5a0hWcoaTDS7 +u2ml/Uji4MvP/d9qIucEHyw5X2PPjGEl4zgSEKXgRRHV9XsESasI3nqKsRe7Yuiq +Vw/3mPE8hP9ePmxbGESmCR972c0qST5mitBZOXuRfB4m1iyCkgHwDr1mG6NfXzYQ +hLYx6mnV0DXpHATPOMfOS2Dm5ATYiX0/4KdmKzaeWiqN+8muAv+rwOL4EwVw9s8u +OLQfaSJIgRQX4dhMAgMCkA== +-----END ENCRYPTED PRIVATE KEY----- diff --git a/tests/keys/public.pem b/tests/keys/public.pem new file mode 100644 index 0000000..652b402 --- /dev/null +++ b/tests/keys/public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqqJgMo49P8vpL9WqFsGo +PXbHaTFwsn5qZjfoazbFn0wjEFZ3Cbv2jebklGhtKwHozl3ZtUeQAzdtL+7QIfkM +5Bt+BP/bZtFwlYqKB1SqMqlGxHh/NnUW4uPLOUXoFj7/c0wLoJcl5X+I1RgWjyK6 +l5BWeQEKnv6w1zPSw3uOiEyysvO9DAQXJ5C9RKMxN2jEUpRxJkmBNSwud0evjzvI +kgBukr9tiUBysf1Ze2ZtmJe6FT7XxoCR4Bwr+TpZYZfG0Pp++LA9IdSVNNQmlvUR +/M9LYGmzZfodt/ymgBxuhoanFcfUjdjhHowJDGWeG+Yk49J1VGsARYB1m+2Kbhdd ++wIDAQAB +-----END PUBLIC KEY-----