diff --git a/README.md b/README.md index db423fc8..f991723c 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ configuration options: * `email`: The email address to use as the account name when logging into the Bitwarden server. Required. +* `client_id`: Client ID part of the API key. Defaults to regular login process if unset. * `sso_id`: The SSO organization ID. Defaults to regular login process if unset. * `base_url`: The URL of the Bitwarden server to use. Defaults to the official server at `https://api.bitwarden.com/` if unset. @@ -102,6 +103,19 @@ between by using the `RBW_PROFILE` environment variable. Setting it to a name switch between several different vaults - each will use its own separate configuration, local vault, and agent. +### Auth methods + +Currently `rbw` supports three login strategies, listed by order of priority: +1. `apikey`, requires you to provide `client_id` and `client_secret`. Will be enabled + when a `client_id` value is set in the config file. `client_secret` can be provided in the + config file, `rbw` will prompt for it via pinentry otherwise +2. `SSO` (Enterprise Single Sign-On). Will be enabled when a `sso_id` value is set in + the config file. (Note: due to the current implementation, if your account is secured with 2FA + you'll be required to go through the browser flow twice. You'll be prompted for the 2FA code + after the first run) +3. `email&password`, regular auth method, uses the same credentials as Bitwarden's Web Vault. + That's most likely what you want to use + ## Usage Commands can generally be used directly, and will handle logging in or diff --git a/src/actions.rs b/src/actions.rs index a201f75f..091c9689 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -7,7 +7,11 @@ pub async fn register( let (client, config) = api_client_async().await?; client - .register(email, &crate::config::device_id(&config).await?, &apikey) + .register( + email, + &crate::config::device_id_async(&config).await?, + &apikey, + ) .await?; Ok(()) @@ -15,6 +19,7 @@ pub async fn register( pub async fn login( email: &str, + apikey: Option, password: crate::locked::Password, two_factor_token: Option<&str>, two_factor_provider: Option, @@ -42,9 +47,10 @@ pub async fn login( let (access_token, refresh_token, protected_key) = client .login( email, + apikey.as_ref(), config.sso_id.as_deref(), - &crate::config::device_id(&config).await?, - &identity.master_password_hash, + &crate::config::device_id_async(&config).await?, + Some(&identity.master_password_hash), two_factor_token, two_factor_provider, ) diff --git a/src/api.rs b/src/api.rs index 7edcc5e9..0dd9377e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -12,6 +12,30 @@ use crate::json::{ DeserializeJsonWithPath as _, DeserializeJsonWithPathAsync as _, }; +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +struct RefreshToken { + #[serde(flatten)] + value: RefreshTokenType, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +#[serde(untagged)] +enum RefreshTokenType { + ApiKey(RefreshTokenApiKey), + Jwt(RefreshTokenJwt), +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +struct RefreshTokenJwt { + refresh_token: String, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +struct RefreshTokenApiKey { + client_id: String, + client_secret: String, +} + #[derive( serde_repr::Serialize_repr, serde_repr::Deserialize_repr, @@ -274,11 +298,19 @@ struct PreloginRes { struct ConnectTokenReq { grant_type: String, scope: String, + #[serde(rename = "deviceIdentifier")] + device_identifier: String, + #[serde(flatten)] + auth: ConnectTokenAuth, + #[serde(flatten)] + base: ConnectTokenBase, +} + +#[derive(serde::Serialize, Debug)] +struct ConnectTokenBase { client_id: String, #[serde(rename = "deviceType")] device_type: u32, - #[serde(rename = "deviceIdentifier")] - device_identifier: String, #[serde(rename = "deviceName")] device_name: String, #[serde(rename = "devicePushToken")] @@ -287,8 +319,19 @@ struct ConnectTokenReq { two_factor_token: Option, #[serde(rename = "twoFactorProvider")] two_factor_provider: Option, - #[serde(flatten)] - auth: ConnectTokenAuth, +} + +impl Default for ConnectTokenBase { + fn default() -> Self { + Self { + client_id: "cli".to_string(), + device_type: 8, + device_name: "rbw".to_string(), + device_push_token: String::new(), + two_factor_token: None, + two_factor_provider: None, + } + } } #[derive(serde::Serialize, Debug)] @@ -326,6 +369,13 @@ struct ConnectTokenRes { key: String, } +#[derive(serde::Deserialize, Debug)] +struct ConnectTokenApiKeyRes { + access_token: String, + #[serde(rename = "Key", alias = "key")] + key: String, +} + #[derive(serde::Deserialize, Debug)] struct ConnectErrorRes { error: String, @@ -869,28 +919,20 @@ impl Client { device_id: &str, apikey: &crate::locked::ApiKey, ) -> Result<()> { - let connect_req = ConnectTokenReq { - auth: ConnectTokenAuth::ClientCredentials( - ConnectTokenClientCredentials { - username: email.to_string(), - client_secret: String::from_utf8( - apikey.client_secret().to_vec(), - ) - .unwrap(), - }, - ), - grant_type: "client_credentials".to_string(), - scope: "api".to_string(), - // XXX unwraps here are not necessarily safe - client_id: String::from_utf8(apikey.client_id().to_vec()) - .unwrap(), - device_type: u32::from(DEVICE_TYPE), - device_identifier: device_id.to_string(), - device_name: "rbw".to_string(), - device_push_token: String::new(), - two_factor_token: None, - two_factor_provider: None, - }; + // XXX unwraps here are not necessarily safe + let client_id = + String::from_utf8(apikey.client_id().to_vec()).unwrap(); + + let client_secret = + String::from_utf8(apikey.client_secret().to_vec()).unwrap(); + + let connect_req = token_endpoint_login_request_apikey( + client_id, + client_secret, + email.to_string(), + device_id.to_string(), + ); + let client = self.reqwest_client().await?; let res = client .post(self.identity_url("/connect/token")) @@ -921,53 +963,67 @@ impl Client { pub async fn login( &self, email: &str, + apikey: Option<&crate::locked::ApiKey>, sso_id: Option<&str>, device_id: &str, - password_hash: &crate::locked::PasswordHash, + password_hash: Option<&crate::locked::PasswordHash>, two_factor_token: Option<&str>, two_factor_provider: Option, ) -> Result<(String, String, String)> { - let connect_req = match sso_id { - Some(sso_id) => { - let (sso_code, sso_code_verifier, callback_url) = - self.obtain_sso_code(sso_id).await?; - - ConnectTokenReq { - auth: ConnectTokenAuth::AuthCode(ConnectTokenAuthCode { - code: sso_code, - code_verifier: sso_code_verifier, - redirect_uri: callback_url, - }), - grant_type: "authorization_code".to_string(), - scope: "api offline_access".to_string(), - client_id: "cli".to_string(), - device_type: u32::from(DEVICE_TYPE), - device_identifier: device_id.to_string(), - device_name: "rbw".to_string(), - device_push_token: String::new(), + let connect_req = if let Some(apikey) = apikey { + // XXX unwraps here are not necessarily safe + let client_id = + String::from_utf8(apikey.client_id().to_vec()).unwrap(); + + let client_secret = + String::from_utf8(apikey.client_secret().to_vec()).unwrap(); + + token_endpoint_login_request_apikey( + client_id, + client_secret, + email.to_string(), + device_id.to_string(), + ) + } else if let Some(sso_id) = sso_id { + let (sso_code, sso_code_verifier, callback_url) = + self.obtain_sso_code(sso_id).await?; + + ConnectTokenReq { + base: ConnectTokenBase { two_factor_token: two_factor_token .map(std::string::ToString::to_string), two_factor_provider: two_factor_provider .map(|ty| ty as u32), - } + ..Default::default() + }, + auth: ConnectTokenAuth::AuthCode(ConnectTokenAuthCode { + code: sso_code, + code_verifier: sso_code_verifier, + redirect_uri: callback_url, + }), + grant_type: "authorization_code".to_string(), + scope: "api offline_access".to_string(), + device_identifier: device_id.to_string(), } - None => ConnectTokenReq { + } else if let Some(password_hash) = password_hash { + ConnectTokenReq { + base: ConnectTokenBase { + two_factor_token: two_factor_token + .map(std::string::ToString::to_string), + two_factor_provider: two_factor_provider + .map(|ty| ty as u32), + ..Default::default() + }, auth: ConnectTokenAuth::Password(ConnectTokenPassword { username: email.to_string(), password: crate::base64::encode(password_hash.hash()), }), - grant_type: "password".to_string(), scope: "api offline_access".to_string(), - client_id: "cli".to_string(), - device_type: 8, device_identifier: device_id.to_string(), - device_name: "rbw".to_string(), - device_push_token: String::new(), - two_factor_token: two_factor_token - .map(std::string::ToString::to_string), - two_factor_provider: two_factor_provider.map(|ty| ty as u32), - }, + } + } else { + unreachable!(); }; let client = self.reqwest_client().await?; @@ -983,11 +1039,47 @@ impl Client { .map_err(|source| Error::Reqwest { source })?; if res.status() == reqwest::StatusCode::OK { - let connect_res: ConnectTokenRes = res.json_with_path().await?; + let body = res.text().await.unwrap(); + let (access_token, refresh_token, key) = if let Ok(connect_res) = + body.clone().json_with_path::() + { + ( + connect_res.access_token, + RefreshToken { + value: RefreshTokenType::Jwt(RefreshTokenJwt { + refresh_token: connect_res.refresh_token, + }), + }, + connect_res.key, + ) + } else { + let connect_res: ConnectTokenApiKeyRes = + body.json_with_path()?; + + let apikey = apikey.unwrap(); + let client_secret = + String::from_utf8(apikey.client_secret().to_vec()) + .unwrap(); + // XXX unwraps here are not necessarily safe + let client_id = + String::from_utf8(apikey.client_id().to_vec()).unwrap(); + + ( + connect_res.access_token, + RefreshToken { + value: RefreshTokenType::ApiKey(RefreshTokenApiKey { + client_id, + client_secret, + }), + }, + connect_res.key, + ) + }; + Ok(( - connect_res.access_token, - connect_res.refresh_token, - connect_res.key, + access_token, + serde_json::to_string(&refresh_token).unwrap(), + key, )) } else { let code = res.status().as_u16(); @@ -1461,40 +1553,126 @@ impl Client { &self, refresh_token: &str, ) -> Result { - let connect_req = ConnectRefreshTokenReq { - grant_type: "refresh_token".to_string(), - client_id: "cli".to_string(), - refresh_token: refresh_token.to_string(), - }; - let client = reqwest::blocking::Client::new(); - let res = client - .post(self.identity_url("/connect/token")) - .form(&connect_req) - .send() - .map_err(|source| Error::Reqwest { source })?; - let connect_res: ConnectRefreshTokenRes = res.json_with_path()?; - Ok(connect_res.access_token) + match deserialize_refresh_token(refresh_token)? { + RefreshToken { + value: + RefreshTokenType::ApiKey(RefreshTokenApiKey { + client_id, + client_secret, + }), + } => { + let config = crate::config::Config::load()?; + let email = config.email()?; + let device_id = crate::config::device_id(&config)?; + + let connect_req = token_endpoint_login_request_apikey( + client_id, + client_secret, + email.clone(), + device_id, + ); + + let client = reqwest::blocking::Client::new(); + let res = client + .post(self.identity_url("/connect/token")) + .form(&connect_req) + .header( + "auth-email", + crate::base64::encode_url_safe_no_pad(email), + ) + .send() + .map_err(|source| Error::Reqwest { source })?; + + let connect_res: ConnectTokenApiKeyRes = + res.json_with_path()?; + + Ok(connect_res.access_token) + } + RefreshToken { + value: + RefreshTokenType::Jwt(RefreshTokenJwt { refresh_token }), + } => { + let connect_req = ConnectRefreshTokenReq { + grant_type: "refresh_token".to_string(), + client_id: "cli".to_string(), + refresh_token, + }; + let client = reqwest::blocking::Client::new(); + let res = client + .post(self.identity_url("/connect/token")) + .form(&connect_req) + .send() + .map_err(|source| Error::Reqwest { source })?; + let connect_res: ConnectRefreshTokenRes = + res.json_with_path()?; + Ok(connect_res.access_token) + } + } } pub async fn exchange_refresh_token_async( &self, refresh_token: &str, ) -> Result { - let connect_req = ConnectRefreshTokenReq { - grant_type: "refresh_token".to_string(), - client_id: "cli".to_string(), - refresh_token: refresh_token.to_string(), - }; - let client = self.reqwest_client().await?; - let res = client - .post(self.identity_url("/connect/token")) - .form(&connect_req) - .send() - .await - .map_err(|source| Error::Reqwest { source })?; - let connect_res: ConnectRefreshTokenRes = - res.json_with_path().await?; - Ok(connect_res.access_token) + match deserialize_refresh_token(refresh_token)? { + RefreshToken { + value: + RefreshTokenType::ApiKey(RefreshTokenApiKey { + client_id, + client_secret, + }), + } => { + let config = crate::config::Config::load()?; + let email = config.email()?; + let device_id = + crate::config::device_id_async(&config).await?; + + let connect_req = token_endpoint_login_request_apikey( + client_id, + client_secret, + email.clone(), + device_id, + ); + + let client = self.reqwest_client().await?; + let res = client + .post(self.identity_url("/connect/token")) + .form(&connect_req) + .header( + "auth-email", + crate::base64::encode_url_safe_no_pad(email), + ) + .send() + .await + .map_err(|source| Error::Reqwest { source })?; + + let connect_res: ConnectTokenApiKeyRes = + res.json_with_path().await?; + + Ok(connect_res.access_token) + } + + RefreshToken { + value: + RefreshTokenType::Jwt(RefreshTokenJwt { refresh_token }), + } => { + let connect_req = ConnectRefreshTokenReq { + grant_type: "refresh_token".to_string(), + client_id: "cli".to_string(), + refresh_token: refresh_token.to_string(), + }; + let client = self.reqwest_client().await?; + let res = client + .post(self.identity_url("/connect/token")) + .form(&connect_req) + .send() + .await + .map_err(|source| Error::Reqwest { source })?; + let connect_res: ConnectRefreshTokenRes = + res.json_with_path().await?; + Ok(connect_res.access_token) + } + } } fn api_url(&self, path: &str) -> String { @@ -1506,6 +1684,36 @@ impl Client { } } +fn token_endpoint_login_request_apikey( + client_id: String, + client_secret: String, + email: String, + device_id: String, +) -> ConnectTokenReq { + ConnectTokenReq { + base: ConnectTokenBase { + client_id, + ..Default::default() + }, + + auth: ConnectTokenAuth::ClientCredentials( + ConnectTokenClientCredentials { + username: email, + client_secret, + }, + ), + grant_type: "client_credentials".to_string(), + scope: "api".to_string(), + device_identifier: device_id, + } +} + +fn deserialize_refresh_token(refresh_token: &str) -> Result { + let jd = &mut serde_json::Deserializer::from_str(refresh_token); + serde_path_to_error::deserialize(jd) + .map_err(|source| Error::Json { source }) +} + async fn find_free_port(bottom: u16, top: u16) -> Result { for port in bottom..top { if tokio::net::TcpListener::bind(("127.0.0.1", port)) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 6f079094..39dfc1dc 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -105,18 +105,78 @@ pub async fn login( } else { None }; - let password = rbw::pinentry::getpin( - &config_pinentry().await?, - "Master Password", - &format!("Log in to {host}"), - err.as_deref(), - environment, - true, + + let client_id = config_client_id().await?; + let apikey = if let Some(client_id) = client_id { + let client_secret = if let Some(client_secret) = + config_client_secret().await? + { + let mut client_secret_vec = rbw::locked::Vec::new(); + client_secret_vec.extend( + client_secret.clone().into_bytes().into_iter(), + ); + client_secret_vec.truncate(client_secret.len()); + + rbw::locked::Password::new(client_secret_vec) + } else { + rbw::pinentry::getpin( + &config_pinentry().await?, + "API key client__secret", + &format!("Log in to {host}"), + err.as_deref(), + environment, + false, + ) + .await + .context("failed to read client_secret from pinentry")? + }; + + let mut client_id_vec = rbw::locked::Vec::new(); + client_id_vec + .extend(client_id.clone().into_bytes().into_iter()); + client_id_vec.truncate(client_id.len()); + + Some(rbw::locked::ApiKey::new( + rbw::locked::Password::new(client_id_vec), + client_secret, + )) + } else { + None + }; + + // TODO: this should be done with a proper Option instead of this dummy WA + // Currently we just setup a "dummy" password so it works with current identity + // implementation + // TODO: probably we could run the same check for the SSO login strategy, + // as password shouldn't be needed there instantly + let password = if apikey.is_none() { + rbw::pinentry::getpin( + &config_pinentry().await?, + "Master Password", + &format!("Log in to {host}"), + err.as_deref(), + environment, + true, + ) + .await + .context("failed to read password from pinentry")? + } else { + let temp_password = "dummy".to_string(); + let mut password_vec = rbw::locked::Vec::new(); + password_vec + .extend(temp_password.clone().into_bytes().into_iter()); + password_vec.truncate(temp_password.len()); + rbw::locked::Password::new(password_vec) + }; + + match rbw::actions::login( + &email, + apikey.clone(), + password.clone(), + None, + None, ) .await - .context("failed to read password from pinentry")?; - match rbw::actions::login(&email, password.clone(), None, None) - .await { Ok(( access_token, @@ -163,6 +223,7 @@ pub async fn login( ) = two_factor( environment, &email, + apikey, password.clone(), provider, ) @@ -214,6 +275,7 @@ pub async fn login( async fn two_factor( environment: &rbw::protocol::Environment, email: &str, + apikey: Option, password: rbw::locked::Password, provider: rbw::api::TwoFactorProviderType, ) -> anyhow::Result<( @@ -248,6 +310,7 @@ async fn two_factor( .context("code was not valid utf8")?; match rbw::actions::login( email, + apikey.clone(), password.clone(), Some(code), Some(provider), @@ -697,6 +760,16 @@ async fn config_pinentry() -> anyhow::Result { Ok(config.pinentry) } +async fn config_client_id() -> anyhow::Result> { + let config = rbw::config::Config::load_async().await?; + Ok(config.client_id) +} + +async fn config_client_secret() -> anyhow::Result> { + let config = rbw::config::Config::load_async().await?; + Ok(config.client_secret) +} + pub async fn subscribe_to_notifications( state: std::sync::Arc>, ) -> anyhow::Result<()> { diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 8433e669..f2138d12 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -893,6 +893,7 @@ pub fn config_set(key: &str, value: &str) -> anyhow::Result<()> { match key { "email" => config.email = Some(value.to_string()), "sso_id" => config.sso_id = Some(value.to_string()), + "client_id" => config.client_id = Some(value.to_string()), "base_url" => config.base_url = Some(value.to_string()), "identity_url" => config.identity_url = Some(value.to_string()), "ui_url" => config.ui_url = Some(value.to_string()), @@ -940,6 +941,7 @@ pub fn config_unset(key: &str) -> anyhow::Result<()> { match key { "email" => config.email = None, "sso_id" => config.sso_id = None, + "client_id" => config.client_id = None, "base_url" => config.base_url = None, "identity_url" => config.identity_url = None, "ui_url" => config.ui_url = None, diff --git a/src/config.rs b/src/config.rs index 178ca817..6708ec4c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,6 +8,8 @@ use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; pub struct Config { pub email: Option, pub sso_id: Option, + pub client_id: Option, + pub client_secret: Option, pub base_url: Option, pub identity_url: Option, pub ui_url: Option, @@ -29,6 +31,8 @@ impl Default for Config { Self { email: None, sso_id: None, + client_id: None, + client_secret: None, base_url: None, identity_url: None, ui_url: None, @@ -143,6 +147,13 @@ impl Config { Ok(()) } + pub fn email(&self) -> Result { + if self.email.is_none() { + return Err(Error::ConfigMissingEmail); + } + Ok(self.email.clone().unwrap()) + } + pub fn base_url(&self) -> String { self.base_url.clone().map_or_else( || "https://api.bitwarden.com".to_string(), @@ -207,7 +218,7 @@ impl Config { } } -pub async fn device_id(config: &Config) -> Result { +pub async fn device_id_async(config: &Config) -> Result { let file = crate::dirs::device_id_file(); if let Ok(mut fh) = tokio::fs::File::open(&file).await { let mut s = String::new(); @@ -238,3 +249,32 @@ pub async fn device_id(config: &Config) -> Result { Ok(id) } } + +pub fn device_id(config: &Config) -> Result { + let file = crate::dirs::device_id_file(); + if let Ok(mut fh) = std::fs::File::open(&file) { + let mut s = String::new(); + fh.read_to_string(&mut s).map_err(|e| Error::LoadDeviceId { + source: e, + file: file.clone(), + })?; + Ok(s.trim().to_string()) + } else { + let id = config.device_id.as_ref().map_or_else( + || uuid::Uuid::new_v4().hyphenated().to_string(), + String::to_string, + ); + let mut fh = std::fs::File::create(&file).map_err(|e| { + Error::LoadDeviceId { + source: e, + file: file.clone(), + } + })?; + fh.write_all(id.as_bytes()) + .map_err(|e| Error::LoadDeviceId { + source: e, + file: file.clone(), + })?; + Ok(id) + } +}