Skip to content

Commit

Permalink
SM-866: Add state to the SDK & BWS CLI (#388)
Browse files Browse the repository at this point in the history
## Type of change

<!-- (mark with an `X`) -->

```
- [ ] Bug fix
- [x] New feature development
- [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc)
- [ ] Build/deploy pipeline (DevOps)
- [ ] Other
```

## Objective

<!--Describe what the purpose of this PR is. For example: what bug
you're fixing or what new feature you're adding-->

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

<!--Explain the changes you've made to each file or major component.
This should help the reviewer understand your changes-->
<!--Also refer to any related changes or PRs in other repositories-->

_(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 <Hinton@users.noreply.github.com>
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
  • Loading branch information
3 people authored Dec 18, 2023
1 parent 65df2ed commit 9d4ec76
Show file tree
Hide file tree
Showing 14 changed files with 195 additions and 15 deletions.
5 changes: 5 additions & 0 deletions crates/bitwarden/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/bitwarden/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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() };
Expand Down
3 changes: 2 additions & 1 deletion crates/bitwarden/src/auth/client_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
63 changes: 60 additions & 3 deletions crates/bitwarden/src/auth/login/access_token.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand All @@ -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,
};
Expand All @@ -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 {
Expand All @@ -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()?;
Expand All @@ -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(),
Expand All @@ -63,6 +90,7 @@ pub(crate) async fn login_access_token(
ServiceAccountLoginMethod::AccessToken {
access_token,
organization_id,
state_file: input.state_file.clone(),
},
));

Expand All @@ -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<Uuid> {
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<PathBuf>,
}

#[derive(Serialize, Deserialize, Debug, JsonSchema)]
Expand Down
28 changes: 24 additions & 4 deletions crates/bitwarden/src/auth/renew.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
}
Expand Down Expand Up @@ -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
}
},
};
Expand Down
9 changes: 6 additions & 3 deletions crates/bitwarden/src/client/client.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::path::PathBuf;

use chrono::Utc;
use reqwest::header::{self};
use uuid::Uuid;
Expand Down Expand Up @@ -62,14 +64,15 @@ pub(crate) enum ServiceAccountLoginMethod {
AccessToken {
access_token: AccessToken,
organization_id: Uuid,
state_file: Option<PathBuf>,
},
}

#[derive(Debug)]
pub struct Client {
token: Option<String>,
pub(crate) refresh_token: Option<String>,
pub(crate) token_expires_in: Option<i64>,
pub(crate) token_expires_on: Option<i64>,
pub(crate) login_method: Option<LoginMethod>,

/// Use Client::get_api_configurations() to access this.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down
6 changes: 6 additions & 0 deletions crates/bitwarden/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>),
}
Expand Down
2 changes: 1 addition & 1 deletion crates/bitwarden/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() };
Expand Down
1 change: 1 addition & 0 deletions crates/bitwarden/src/secrets_manager/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod projects;
pub mod secrets;
pub mod state;

mod client_projects;
mod client_secrets;
Expand Down
52 changes: 52 additions & 0 deletions crates/bitwarden/src/secrets_manager/state.rs
Original file line number Diff line number Diff line change
@@ -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<ClientState> {
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())
}
2 changes: 2 additions & 0 deletions crates/bws/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion crates/bws/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub(crate) struct Profile {
pub server_base: Option<String>,
pub server_api: Option<String>,
pub server_identity: Option<String>,
pub state_file_dir: Option<String>,
}

// TODO: This could probably be derived with a macro if we start adding more fields
Expand All @@ -28,6 +29,7 @@ pub(crate) enum ProfileKey {
server_base,
server_api,
server_identity,
state_file_dir,
}

impl ProfileKey {
Expand All @@ -36,14 +38,15 @@ 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),
}
}
}

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)
Expand Down Expand Up @@ -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<String> {
Expand Down
Loading

0 comments on commit 9d4ec76

Please sign in to comment.