From 9d4ec76d2811bf4d2e52b7a0d0cba029f3ba42c6 Mon Sep 17 00:00:00 2001 From: Colton Hurst Date: Mon, 18 Dec 2023 13:27:40 -0500 Subject: [PATCH] SM-866: Add state to the SDK & BWS CLI (#388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Type of change ``` - [ ] Bug fix - [x] New feature development - [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc) - [ ] Build/deploy pipeline (DevOps) - [ ] Other ``` ## Objective This PR adds the ability to manage basic state via the SDK. It also updates the `bws` crate to use this SDK change, implementing basic state management for auth tokens. The core benefit is that reauthentication per command will no longer be needed, so that server auth requests will be mitigated. ## Code changes _(to be added)_ - **file.ext:** Description of what was changed and why ## Before you submit - Please add **unit tests** where it makes sense to do so (encouraged but not required) --------- Co-authored-by: Oscar Hinton Co-authored-by: Daniel García --- crates/bitwarden/CHANGELOG.md | 5 ++ crates/bitwarden/README.md | 2 +- crates/bitwarden/src/auth/client_auth.rs | 3 +- .../bitwarden/src/auth/login/access_token.rs | 63 ++++++++++++++++++- crates/bitwarden/src/auth/renew.rs | 28 +++++++-- crates/bitwarden/src/client/client.rs | 9 ++- crates/bitwarden/src/error.rs | 6 ++ crates/bitwarden/src/lib.rs | 2 +- crates/bitwarden/src/secrets_manager/mod.rs | 1 + crates/bitwarden/src/secrets_manager/state.rs | 52 +++++++++++++++ crates/bws/CHANGELOG.md | 2 + crates/bws/src/config.rs | 6 +- crates/bws/src/main.rs | 13 +++- crates/bws/src/state.rs | 18 ++++++ 14 files changed, 195 insertions(+), 15 deletions(-) create mode 100644 crates/bitwarden/src/secrets_manager/state.rs create mode 100644 crates/bws/src/state.rs diff --git a/crates/bitwarden/CHANGELOG.md b/crates/bitwarden/CHANGELOG.md index 8bb18ff72..1341eef68 100644 --- a/crates/bitwarden/CHANGELOG.md +++ b/crates/bitwarden/CHANGELOG.md @@ -7,6 +7,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- Support for basic state to avoid reauthenticating when creating a new `Client`. This is a breaking + change because of adding `state_file` to the `AccessTokenLoginRequest` struct. (#388) + ### Deprecated - `client.access_token_login()` is now deprecated and will be removed in a future release. Please diff --git a/crates/bitwarden/README.md b/crates/bitwarden/README.md index 67c347583..abb2b5dd4 100644 --- a/crates/bitwarden/README.md +++ b/crates/bitwarden/README.md @@ -41,7 +41,7 @@ async fn test() -> Result<()> { let mut client = Client::new(Some(settings)); // Before we operate, we need to authenticate with a token - let token = AccessTokenLoginRequest { access_token: String::from("") }; + let token = AccessTokenLoginRequest { access_token: String::from(""), state_file: None }; client.auth().login_access_token(&token).await.unwrap(); let org_id = SecretIdentifiersRequest { organization_id: Uuid::parse_str("00000000-0000-0000-0000-000000000000").unwrap() }; diff --git a/crates/bitwarden/src/auth/client_auth.rs b/crates/bitwarden/src/auth/client_auth.rs index 3c096835a..6c9ddbd4d 100644 --- a/crates/bitwarden/src/auth/client_auth.rs +++ b/crates/bitwarden/src/auth/client_auth.rs @@ -170,7 +170,8 @@ mod tests { .auth() .login_access_token(&AccessTokenLoginRequest { access_token: "0.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==".into(), - }) + state_file: None, + },) .await .unwrap(); assert!(res.authenticated); diff --git a/crates/bitwarden/src/auth/login/access_token.rs b/crates/bitwarden/src/auth/login/access_token.rs index ed664437d..2e11035c6 100644 --- a/crates/bitwarden/src/auth/login/access_token.rs +++ b/crates/bitwarden/src/auth/login/access_token.rs @@ -1,6 +1,10 @@ +use std::path::{Path, PathBuf}; + use base64::Engine; +use chrono::Utc; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use uuid::Uuid; use crate::{ auth::{ @@ -11,6 +15,7 @@ use crate::{ client::{AccessToken, LoginMethod, ServiceAccountLoginMethod}, crypto::{EncString, KeyDecryptable, SymmetricCryptoKey}, error::{Error, Result}, + secrets_manager::state::{self, ClientState}, util::BASE64_ENGINE, Client, }; @@ -24,6 +29,25 @@ pub(crate) async fn login_access_token( let access_token: AccessToken = input.access_token.parse()?; + if let Some(state_file) = &input.state_file { + if let Ok(organization_id) = load_tokens_from_state(client, state_file, &access_token) { + client.set_login_method(LoginMethod::ServiceAccount( + ServiceAccountLoginMethod::AccessToken { + access_token, + organization_id, + state_file: Some(state_file.to_path_buf()), + }, + )); + + return Ok(AccessTokenLoginResponse { + authenticated: true, + reset_master_password: false, + force_password_reset: false, + two_factor: None, + }); + } + } + let response = request_access_token(client, &access_token).await?; if let IdentityTokenResponse::Payload(r) = &response { @@ -40,9 +64,7 @@ pub(crate) async fn login_access_token( } let payload: Payload = serde_json::from_slice(&decrypted_payload)?; - - let encryption_key = BASE64_ENGINE.decode(payload.encryption_key)?; - + let encryption_key = BASE64_ENGINE.decode(payload.encryption_key.clone())?; let encryption_key = SymmetricCryptoKey::try_from(encryption_key.as_slice())?; let access_token_obj: JWTToken = r.access_token.parse()?; @@ -54,6 +76,11 @@ pub(crate) async fn login_access_token( .parse() .map_err(|_| Error::InvalidResponse)?; + if let Some(state_file) = &input.state_file { + let state = ClientState::new(r.access_token.clone(), payload.encryption_key); + _ = state::set(state_file, &access_token, state); + } + client.set_tokens( r.access_token.clone(), r.refresh_token.clone(), @@ -63,6 +90,7 @@ pub(crate) async fn login_access_token( ServiceAccountLoginMethod::AccessToken { access_token, organization_id, + state_file: input.state_file.clone(), }, )); @@ -82,12 +110,41 @@ async fn request_access_token( .await } +fn load_tokens_from_state( + client: &mut Client, + state_file: &Path, + access_token: &AccessToken, +) -> Result { + let client_state = state::get(state_file, access_token)?; + + let token: JWTToken = client_state.token.parse()?; + + if let Some(organization_id) = token.organization { + let time_till_expiration = (token.exp as i64) - Utc::now().timestamp(); + + if time_till_expiration > 0 { + let organization_id: Uuid = organization_id + .parse() + .map_err(|_| "Bad organization id.")?; + let encryption_key: SymmetricCryptoKey = client_state.encryption_key.parse()?; + + client.set_tokens(client_state.token, None, time_till_expiration as u64); + client.initialize_crypto_single_key(encryption_key); + + return Ok(organization_id); + } + } + + Err(Error::InvalidStateFile) +} + /// Login to Bitwarden with access token #[derive(Serialize, Deserialize, Debug, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AccessTokenLoginRequest { /// Bitwarden service API access token pub access_token: String, + pub state_file: Option, } #[derive(Serialize, Deserialize, Debug, JsonSchema)] diff --git a/crates/bitwarden/src/auth/renew.rs b/crates/bitwarden/src/auth/renew.rs index fe1920e62..6f93f7482 100644 --- a/crates/bitwarden/src/auth/renew.rs +++ b/crates/bitwarden/src/auth/renew.rs @@ -6,12 +6,13 @@ use crate::{ auth::api::{request::AccessTokenRequest, response::IdentityTokenResponse}, client::{Client, LoginMethod, ServiceAccountLoginMethod}, error::{Error, Result}, + secrets_manager::state::{self, ClientState}, }; pub(crate) async fn renew_token(client: &mut Client) -> Result<()> { const TOKEN_RENEW_MARGIN_SECONDS: i64 = 5 * 60; - if let (Some(expires), Some(login_method)) = (&client.token_expires_in, &client.login_method) { + if let (Some(expires), Some(login_method)) = (&client.token_expires_on, &client.login_method) { if Utc::now().timestamp() < expires - TOKEN_RENEW_MARGIN_SECONDS { return Ok(()); } @@ -43,13 +44,32 @@ pub(crate) async fn renew_token(client: &mut Client) -> Result<()> { } }, LoginMethod::ServiceAccount(s) => match s { - ServiceAccountLoginMethod::AccessToken { access_token, .. } => { - AccessTokenRequest::new( + ServiceAccountLoginMethod::AccessToken { + access_token, + state_file, + .. + } => { + let result = AccessTokenRequest::new( access_token.access_token_id, &access_token.client_secret, ) .send(&client.__api_configurations) - .await? + .await?; + + if let ( + IdentityTokenResponse::Authenticated(r), + Some(state_file), + Ok(enc_settings), + ) = (&result, state_file, client.get_encryption_settings()) + { + if let Some(enc_key) = enc_settings.get_key(&None) { + let state = + ClientState::new(r.access_token.clone(), enc_key.to_base64()); + _ = state::set(state_file, access_token, state); + } + } + + result } }, }; diff --git a/crates/bitwarden/src/client/client.rs b/crates/bitwarden/src/client/client.rs index bb0f65c09..1057532a6 100644 --- a/crates/bitwarden/src/client/client.rs +++ b/crates/bitwarden/src/client/client.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use chrono::Utc; use reqwest::header::{self}; use uuid::Uuid; @@ -62,6 +64,7 @@ pub(crate) enum ServiceAccountLoginMethod { AccessToken { access_token: AccessToken, organization_id: Uuid, + state_file: Option, }, } @@ -69,7 +72,7 @@ pub(crate) enum ServiceAccountLoginMethod { pub struct Client { token: Option, pub(crate) refresh_token: Option, - pub(crate) token_expires_in: Option, + pub(crate) token_expires_on: Option, pub(crate) login_method: Option, /// Use Client::get_api_configurations() to access this. @@ -114,7 +117,7 @@ impl Client { Self { token: None, refresh_token: None, - token_expires_in: None, + token_expires_on: None, login_method: None, __api_configurations: ApiConfigurations { identity, @@ -193,7 +196,7 @@ impl Client { ) { self.token = Some(token.clone()); self.refresh_token = refresh_token; - self.token_expires_in = Some(Utc::now().timestamp() + expires_in as i64); + self.token_expires_on = Some(Utc::now().timestamp() + expires_in as i64); self.__api_configurations.identity.oauth_access_token = Some(token.clone()); self.__api_configurations.api.oauth_access_token = Some(token); } diff --git a/crates/bitwarden/src/error.rs b/crates/bitwarden/src/error.rs index e5560b479..ede8ef806 100644 --- a/crates/bitwarden/src/error.rs +++ b/crates/bitwarden/src/error.rs @@ -46,6 +46,12 @@ pub enum Error { #[error("Received error message from server: [{}] {}", .status, .message)] ResponseContent { status: StatusCode, message: String }, + #[error("The state file version is invalid")] + InvalidStateFileVersion, + + #[error("The state file could not be read")] + InvalidStateFile, + #[error("Internal error: {0}")] Internal(Cow<'static, str>), } diff --git a/crates/bitwarden/src/lib.rs b/crates/bitwarden/src/lib.rs index 0017ecff9..a8dd60399 100644 --- a/crates/bitwarden/src/lib.rs +++ b/crates/bitwarden/src/lib.rs @@ -38,7 +38,7 @@ //! let mut client = Client::new(Some(settings)); //! //! // Before we operate, we need to authenticate with a token -//! let token = AccessTokenLoginRequest { access_token: String::from("") }; +//! let token = AccessTokenLoginRequest { access_token: String::from(""), state_file: None }; //! client.auth().login_access_token(&token).await.unwrap(); //! //! let org_id = SecretIdentifiersRequest { organization_id: Uuid::parse_str("00000000-0000-0000-0000-000000000000").unwrap() }; diff --git a/crates/bitwarden/src/secrets_manager/mod.rs b/crates/bitwarden/src/secrets_manager/mod.rs index 27b84121e..181edf6b6 100644 --- a/crates/bitwarden/src/secrets_manager/mod.rs +++ b/crates/bitwarden/src/secrets_manager/mod.rs @@ -1,5 +1,6 @@ pub mod projects; pub mod secrets; +pub mod state; mod client_projects; mod client_secrets; diff --git a/crates/bitwarden/src/secrets_manager/state.rs b/crates/bitwarden/src/secrets_manager/state.rs new file mode 100644 index 000000000..d39603d34 --- /dev/null +++ b/crates/bitwarden/src/secrets_manager/state.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ + client::AccessToken, + crypto::{EncString, KeyDecryptable, KeyEncryptable}, + error::{Error, Result}, +}; +use std::{fmt::Debug, path::Path}; + +const STATE_VERSION: u32 = 1; + +#[cfg(feature = "secrets")] +#[derive(Serialize, Deserialize, Debug)] +pub struct ClientState { + pub(crate) version: u32, + pub(crate) token: String, + pub(crate) encryption_key: String, +} + +impl ClientState { + pub fn new(token: String, encryption_key: String) -> Self { + Self { + version: STATE_VERSION, + token, + encryption_key, + } + } +} + +pub fn get(state_file: &Path, access_token: &AccessToken) -> Result { + let file_content = std::fs::read_to_string(state_file)?; + + let encrypted_state: EncString = file_content.parse()?; + let decrypted_state: String = encrypted_state.decrypt_with_key(&access_token.encryption_key)?; + let client_state: ClientState = serde_json::from_str(&decrypted_state)?; + + if client_state.version != STATE_VERSION { + return Err(Error::InvalidStateFileVersion); + } + + Ok(client_state) +} + +pub fn set(state_file: &Path, access_token: &AccessToken, state: ClientState) -> Result<()> { + let serialized_state: String = serde_json::to_string(&state)?; + let encrypted_state: EncString = + serialized_state.encrypt_with_key(&access_token.encryption_key)?; + let state_string: String = encrypted_state.to_string(); + + std::fs::write(state_file, state_string) + .map_err(|_| "Failure writing to the state file.".into()) +} diff --git a/crates/bws/CHANGELOG.md b/crates/bws/CHANGELOG.md index 3ae1d9009..30db20a04 100644 --- a/crates/bws/CHANGELOG.md +++ b/crates/bws/CHANGELOG.md @@ -10,6 +10,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added - Ability to output secrets in an `env` format with `bws` (#320) +- Basic state to avoid reauthenticating every run, used when setting the `state_file_dir` key in the + config (#388) ## [0.3.1] - 2023-10-13 diff --git a/crates/bws/src/config.rs b/crates/bws/src/config.rs index ecad2f6a5..0ea1259f9 100644 --- a/crates/bws/src/config.rs +++ b/crates/bws/src/config.rs @@ -19,6 +19,7 @@ pub(crate) struct Profile { pub server_base: Option, pub server_api: Option, pub server_identity: Option, + pub state_file_dir: Option, } // TODO: This could probably be derived with a macro if we start adding more fields @@ -28,6 +29,7 @@ pub(crate) enum ProfileKey { server_base, server_api, server_identity, + state_file_dir, } impl ProfileKey { @@ -36,6 +38,7 @@ impl ProfileKey { ProfileKey::server_base => p.server_base = Some(value), ProfileKey::server_api => p.server_api = Some(value), ProfileKey::server_identity => p.server_identity = Some(value), + ProfileKey::state_file_dir => p.state_file_dir = Some(value), } } } @@ -43,7 +46,7 @@ impl ProfileKey { pub(crate) const FILENAME: &str = "config"; pub(crate) const DIRECTORY: &str = ".bws"; -fn get_config_path(config_file: Option<&Path>, ensure_folder_exists: bool) -> PathBuf { +pub(crate) fn get_config_path(config_file: Option<&Path>, ensure_folder_exists: bool) -> PathBuf { let config_file = config_file.map(ToOwned::to_owned).unwrap_or_else(|| { let base_dirs = BaseDirs::new().unwrap(); base_dirs.home_dir().join(DIRECTORY).join(FILENAME) @@ -118,6 +121,7 @@ impl Profile { server_base: Some(url.to_string()), server_api: None, server_identity: None, + state_file_dir: None, }) } pub(crate) fn api_url(&self) -> Result { diff --git a/crates/bws/src/main.rs b/crates/bws/src/main.rs index 22e3b5dcd..117186663 100644 --- a/crates/bws/src/main.rs +++ b/crates/bws/src/main.rs @@ -21,6 +21,7 @@ use log::error; mod config; mod render; +mod state; use config::ProfileKey; use render::{serialize_response, Color, Output}; @@ -302,6 +303,7 @@ async fn process_commands() -> Result<()> { Some(key) => key, None => bail!("Missing access token"), }; + let access_token_obj: AccessToken = access_token.parse()?; let profile = get_config_profile( &cli.server_url, @@ -311,6 +313,7 @@ async fn process_commands() -> Result<()> { )?; let settings = profile + .clone() .map(|p| -> Result<_> { Ok(ClientSettings { identity_url: p.identity_url()?, @@ -320,12 +323,20 @@ async fn process_commands() -> Result<()> { }) .transpose()?; + let state_file_path = state::get_state_file_path( + profile.and_then(|p| p.state_file_dir).map(Into::into), + access_token_obj.access_token_id.to_string(), + ); + let mut client = bitwarden::Client::new(settings); // Load session or return if no session exists let _ = client .auth() - .login_access_token(&AccessTokenLoginRequest { access_token }) + .login_access_token(&AccessTokenLoginRequest { + access_token, + state_file: state_file_path, + }) .await?; let organization_id = match client.get_access_token_organization() { diff --git a/crates/bws/src/state.rs b/crates/bws/src/state.rs new file mode 100644 index 000000000..ef0ef07e2 --- /dev/null +++ b/crates/bws/src/state.rs @@ -0,0 +1,18 @@ +use std::path::PathBuf; + +pub(crate) fn get_state_file_path( + state_file_dir: Option, + access_token_id: String, +) -> Option { + if let Some(mut state_file_path) = state_file_dir { + state_file_path.push(access_token_id); + + if let Some(parent_folder) = state_file_path.parent() { + std::fs::create_dir_all(parent_folder).unwrap(); + } + + return Some(state_file_path); + } + + None +}