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 +}