diff --git a/.env.template b/.env.template index c4e391e4..a37b02de 100644 --- a/.env.template +++ b/.env.template @@ -422,15 +422,21 @@ # YUBICO_SERVER=http://yourdomain.com/wsapi/2.0/verify ## Duo Settings -## You need to configure all options to enable global Duo support, otherwise users would need to configure it themselves +## You need to configure the DUO_IKEY, DUO_SKEY, and DUO_HOST options to enable global Duo support. +## Otherwise users will need to configure it themselves. ## Create an account and protect an application as mentioned in this link (only the first step, not the rest): ## https://help.bitwarden.com/article/setup-two-step-login-duo/#create-a-duo-security-account ## Then set the following options, based on the values obtained from the last step: -# DUO_IKEY= -# DUO_SKEY= +# DUO_IKEY= +# DUO_SKEY= # DUO_HOST= ## After that, you should be able to follow the rest of the guide linked above, ## ignoring the fields that ask for the values that you already configured beforehand. +## +## If you want to attempt to use Duo's 'Traditional Prompt' (deprecated, iframe based) set DUO_USE_IFRAME to 'true'. +## Duo no longer supports this, but it still works for some integrations. +## If you aren't sure, leave this alone. +# DUO_USE_IFRAME=false ## Email 2FA settings ## Email token size diff --git a/src/api/core/two_factor/duo.rs b/src/api/core/two_factor/duo.rs index 8554999c..3993397c 100644 --- a/src/api/core/two_factor/duo.rs +++ b/src/api/core/two_factor/duo.rs @@ -252,7 +252,7 @@ async fn get_user_duo_data(uuid: &str, conn: &mut DbConn) -> DuoStatus { } // let (ik, sk, ak, host) = get_duo_keys(); -async fn get_duo_keys_email(email: &str, conn: &mut DbConn) -> ApiResult<(String, String, String, String)> { +pub(crate) async fn get_duo_keys_email(email: &str, conn: &mut DbConn) -> ApiResult<(String, String, String, String)> { let data = match User::find_by_mail(email, conn).await { Some(u) => get_user_duo_data(&u.uuid, conn).await.data(), _ => DuoData::global(), diff --git a/src/api/core/two_factor/duo_oidc.rs b/src/api/core/two_factor/duo_oidc.rs new file mode 100644 index 00000000..a887f3b1 --- /dev/null +++ b/src/api/core/two_factor/duo_oidc.rs @@ -0,0 +1,473 @@ +use chrono::{TimeDelta, Utc}; +use jsonwebtoken::{decode_header, Algorithm, DecodingKey, EncodingKey, Header, Validation}; +use reqwest::{header, StatusCode}; +use serde::Serialize; +use std::collections::HashMap; +use url::Url; +use crate::{ + api::{core::two_factor::duo::get_duo_keys_email, EmptyResult}, + auth::ClientType, + crypto, + db::{models::EventType, DbConn}, + error::Error, + util::get_reqwest_client, + CONFIG, +}; + +// Pool of characters for state and nonce generation +// 0-9 -> 0x30-0x39 +// A-Z -> 0x41-0x5A +// a-z -> 0x61-0x7A +const STATE_CHAR_POOL: [u8; 62] = [ + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, + 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x61, 0x62, + 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, + 0x76, 0x77, 0x78, 0x79, 0x7A, +]; + +const MIN_STATE_SIZE: usize = 16; +const MAX_STATE_SIZE: usize = 1024; +const STATE_LENGTH: usize = 36; // Default size of state for generate_state_default() + +// Client URL constants. Defined as macros, so they can be passed into format!() +#[allow(non_snake_case)] +macro_rules! HEALTH_ENDPOINT { + () => { + "https://{}/oauth/v1/health_check" + }; +} +#[allow(non_snake_case)] +macro_rules! AUTHZ_ENDPOINT { + () => { + "https://{}/oauth/v1/authorize" + }; +} +#[allow(non_snake_case)] +macro_rules! API_HOST_FMT { + () => { + "https://{}" + }; +} +#[allow(non_snake_case)] +macro_rules! TOKEN_ENDPOINT { + () => { + "https://{}/oauth/v1/token" + }; +} + +// Default JWT validity time +const JWT_VALIDITY_SECS: i64 = 300; + +// Generate a new Duo WebSDKv4 state string with a given size. +// This can also be used to generate the optional OpenID Connect nonce. +// Size must be between 16 and 1024 (inclusive). +pub fn generate_state_len(size: usize) -> String { + if (size < MIN_STATE_SIZE) || (MAX_STATE_SIZE < size) { + panic!("Illegal Duo state size: {size}. Size must be 15 < size < 1025") + } + + return crypto::get_random_string(&STATE_CHAR_POOL, size); +} + +pub fn generate_state_default() -> String { + return generate_state_len(STATE_LENGTH); +} + +// Structs for serializing calls to Duo +#[derive(Debug, Serialize, Deserialize)] +struct ClientAssertionJwt { + pub iss: String, + pub sub: String, + pub aud: String, + pub exp: i64, + pub jti: String, + pub iat: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +struct AuthUrlJwt { + pub response_type: String, + pub scope: String, + pub exp: i64, + pub client_id: String, + pub redirect_uri: String, + pub state: String, + pub duo_uname: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub iss: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub aud: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nonce: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub use_duo_code_attribute: Option, +} + +/* +Structs for deserializing responses from Duo's API +*/ +#[derive(Debug, Serialize, Deserialize)] +struct HealthOKTS { + timestamp: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +enum HealthCheckResponse { + HealthOK { + stat: String, + response: HealthOKTS, + }, + HealthFail { + stat: String, + code: i32, + timestamp: i64, + message: String, + message_detail: String, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +struct IdTokenResponse { + id_token: String, + access_token: String, + expires_in: i64, + token_type: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct IdTokenClaims { + aud: String, + iss: String, + preferred_username: String, + #[serde(skip_serializing_if = "Option::is_none")] + nonce: Option, +} + +// Duo WebSDK 4 Client +struct DuoClient { + client_id: String, // Duo Client ID (DuoData.ik) + client_secret: String, // Duo Client Secret (DuoData.sk) + api_host: String, // Duo API hostname (DuoData.host) + redirect_uri: String, // URL in this application clients should call for MFA verification + jwt_exp_seconds: i64, // Number of seconds that JWTs we create should be valid for +} +// TODO: Cert pinning for calls to Duo? + +// See https://duo.com/docs/oauthapi +impl DuoClient { + fn new(client_id: String, client_secret: String, api_host: String, redirect_uri: String) -> DuoClient { + return DuoClient { + client_id, + client_secret, + api_host, + redirect_uri, + jwt_exp_seconds: JWT_VALIDITY_SECS, + }; + } + + // Given a serde-serializable struct, attempt to encode it as a JWT + fn encode_duo_jwt(&self, jwt_payload: T) -> Result { + match jsonwebtoken::encode( + &Header::new(Algorithm::HS512), + &jwt_payload, + &EncodingKey::from_secret(&self.client_secret.as_bytes()), + ) { + Ok(token) => Ok(token), + Err(e) => err!(format!("{}", e)), + } + } + + // "required" health check to verify the integration is configured and Duo's services + // are up. + // https://duo.com/docs/oauthapi#health-check + async fn health_check(&self) -> Result<(), Error> { + let health_check_url: String = format!(HEALTH_ENDPOINT!(), self.api_host); + + let now = Utc::now(); + let jwt_id = generate_state_default(); + let jwt_payload = ClientAssertionJwt { + iss: self.client_id.clone(), + sub: self.client_id.clone(), + aud: health_check_url.clone(), + exp: (now + TimeDelta::try_seconds(self.jwt_exp_seconds).unwrap()).timestamp(), + jti: jwt_id, + iat: now.timestamp(), + }; + + let token = match self.encode_duo_jwt(jwt_payload) { + Ok(token) => token, + Err(e) => err!(format!("{}", e)), + }; + + let mut post_body = HashMap::new(); + post_body.insert("client_assertion", token); + post_body.insert("client_id", self.client_id.clone()); + + let res = match get_reqwest_client() + .post(health_check_url) + .header(header::USER_AGENT, "vaultwarden:Duo/2.0 (Rust)") + .form(&post_body) + .send() + .await + { + Ok(r) => r, + Err(e) => err!(format!("Error requesting Duo health check: {}", e)), + }; + + let response: HealthCheckResponse = match res.json::().await { + Ok(r) => r, + Err(e) => err!(format!("Duo health check response decode error: {}", e)), + }; + + let health_stat: String = match response { + HealthCheckResponse::HealthOK { + stat, + response: _, + } => stat, + HealthCheckResponse::HealthFail { + stat: _, + code: _, + timestamp: _, + message, + message_detail, + } => err!(format!("Duo health check FAIL response msg: {}, detail: {}", message, message_detail)), + }; + + if health_stat != "OK" { + err!("Duo health check returned OK-like body but did not contain an OK stat."); + } + + Ok(()) + } + + // Constructs the URL for the authorization request endpoint on Duo's service. + // Clients are sent here to continue authentication. + // https://duo.com/docs/oauthapi#authorization-request + fn make_authz_req_url(&self, duo_username: &str, state: String, nonce: Option) -> Result { + let now = Utc::now(); + + let jwt_payload = AuthUrlJwt { + response_type: String::from("code"), + scope: String::from("openid"), + exp: (now + TimeDelta::try_seconds(self.jwt_exp_seconds).unwrap()).timestamp(), + client_id: self.client_id.clone(), + redirect_uri: self.redirect_uri.clone(), + state, + duo_uname: String::from(duo_username), + iss: Some(self.client_id.clone()), + aud: Some(format!(API_HOST_FMT!(), self.api_host)), + nonce, + use_duo_code_attribute: Some(false), + }; + + let token = match self.encode_duo_jwt(jwt_payload) { + Ok(token) => token, + Err(e) => err!(format!("{}", e)), + }; + + let authz_endpoint = format!(AUTHZ_ENDPOINT!(), self.api_host); + let mut auth_url = match Url::parse(authz_endpoint.as_str()) { + Ok(url) => url, + Err(e) => err!(format!("{}", e)), + }; + + { + let mut query_params = auth_url.query_pairs_mut(); + query_params.append_pair("response_type", "code"); + query_params.append_pair("client_id", self.client_id.as_str()); + query_params.append_pair("request", token.as_str()); + } + + let final_auth_url = auth_url.to_string(); + return Ok(final_auth_url); + } + + async fn exchange_authz_code_for_result( + &self, + duo_code: &str, + duo_username: &str, + nonce: Option<&str>, + ) -> Result<(), Error> { + if duo_code == "" { + err!("Invalid Duo Code") + } + + let now = Utc::now(); + + let token_url = format!(TOKEN_ENDPOINT!(), self.api_host); + let jwt_id = generate_state_default(); + + let jwt_payload = ClientAssertionJwt { + iss: self.client_id.clone(), + sub: self.client_id.clone(), + aud: token_url.clone(), + exp: (now + TimeDelta::try_seconds(self.jwt_exp_seconds).unwrap()).timestamp(), + jti: jwt_id, + iat: now.timestamp(), + }; + + let token = match self.encode_duo_jwt(jwt_payload) { + Ok(token) => token, + Err(e) => err!(format!("{}", e)), + }; + + let mut post_body = HashMap::new(); + post_body.insert("grant_type", String::from("authorization_code")); + post_body.insert("code", String::from(duo_code)); + post_body.insert("redirect_uri", self.redirect_uri.clone()); + post_body + .insert("client_assertion_type", String::from("urn:ietf:params:oauth:client-assertion-type:jwt-bearer")); + post_body.insert("client_assertion", token); + + let res = match get_reqwest_client() + .post(token_url.clone()) + .header(header::USER_AGENT, "vaultwarden:Duo/2.0 (Rust)") + .form(&post_body) + .send() + .await + { + Ok(r) => r, + Err(e) => err!(format!("Error exchanging Duo code: {}", e)), + }; + + let status_code = res.status(); + if status_code != StatusCode::OK { + err!(format!("Failure response from Duo: {}", status_code)) + } + + let response: IdTokenResponse = match res.json::().await { + Ok(r) => r, + Err(e) => err!(format!("Error decoding ID token response: {}", e)), + }; + + let header = decode_header(&response.id_token).unwrap(); + + let mut validation = Validation::new(header.alg); + validation.set_required_spec_claims(&["exp", "aud", "iss"]); + validation.set_audience(&[&self.client_id]); + validation.set_issuer(&[token_url.as_str()]); + + let token_data = match jsonwebtoken::decode::( + &response.id_token, + &DecodingKey::from_secret(self.client_secret.as_bytes()), + &validation, + ) { + Ok(c) => c, + Err(e) => err!(format!("Failed to decode Duo token {}", e)), + }; + + if !crypto::ct_eq(&duo_username, &token_data.claims.preferred_username) { + err!(format!( + "Error validating Duo user, expected {}, got {}", + duo_username, token_data.claims.preferred_username + )) + }; + + match nonce { + Some(nonce) => { + _ = nonce; // FIXME: Add Nonce support + Ok(()) + } + None => Ok(()), + } + } +} + +// Construct the url that Duo should redirect users to. +// The actual location is a bridge built in to the clients. +// See: /clients/apps/web/src/connectors/duo-redirect.ts +fn make_callback_url(client_name: &str) -> Result { + const DUO_REDIRECT_LOCATION: &str = "duo-redirect-connector.html"; + + // Get the location of this application as defined in the config. + let base = match Url::parse(CONFIG.domain().as_str()) { + Ok(url) => url, + Err(e) => err!(format!("{}", e)), + }; + + // Add the client redirect bridge location + let mut callback = match base.join(DUO_REDIRECT_LOCATION) { + Ok(url) => url, + Err(e) => err!(format!("{}", e)), + }; + + // Add the 'client' string. This is sent by clients in the 'Bitwarden-Client-Name' + // HTTP header of the request to /identity/connect/token + { + let mut query_params = callback.query_pairs_mut(); + query_params.append_pair("client", client_name); + } + return Ok(callback.to_string()); +} + +// Initiates the first stage of the Duo WebSDKv4 authentication flow. +// Returns the "AuthUrl" that should be passed to clients for MFA. +pub async fn get_duo_auth_url(email: &str, client_type: &ClientType, conn: &mut DbConn) -> Result { + let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?; + + let callback_url = match make_callback_url(client_type.as_str()) { + Ok(url) => url, + Err(e) => err!(format!("{}", e)), + }; + + let client = DuoClient::new(ik, sk, host, callback_url); + + match client.health_check().await { + Ok(()) => {} + Err(e) => err!(format!("{}", e)), + }; + + // Generate a random Duo state and OIDC Nonce + let state = generate_state_default(); + + return client.make_authz_req_url(email, state, None); +} + +pub async fn validate_duo_login( + email: &str, + two_factor_token: &str, + client_type: &ClientType, + conn: &mut DbConn, +) -> EmptyResult { + let email = &email.to_lowercase(); + + let split: Vec<&str> = two_factor_token.split('|').collect(); + if split.len() != 2 { + err!( + "Invalid response length", + ErrorEvent { + event: EventType::UserFailedLogIn2fa + } + ); + } + + let code = split[0]; + //let state = split[1]; + + let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?; + + let callback_url = match make_callback_url(client_type.as_str()) { + Ok(url) => url, + Err(e) => err!(format!("{}", e)), + }; + + let client = DuoClient::new(ik, sk, host, callback_url); + + match client.health_check().await { + Ok(()) => {} + Err(e) => err!(format!("{}", e)), + }; + + match client.exchange_authz_code_for_result(code, email, None).await { + Ok(_r) => Ok(()), + Err(_e) => { + err!( + "Error validating duo authentication", + ErrorEvent { + event: EventType::UserFailedLogIn2fa + } + ) + } + } +} diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index 2fbcfb3b..b172f3b5 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -23,6 +23,7 @@ pub mod email; pub mod protected_actions; pub mod webauthn; pub mod yubikey; +pub mod duo_oidc; pub fn routes() -> Vec { let mut routes = routes![ diff --git a/src/api/identity.rs b/src/api/identity.rs index fbf8d506..e066ba5c 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -12,12 +12,12 @@ use crate::{ core::{ accounts::{PreloginData, RegisterData, _prelogin, _register}, log_user_event, - two_factor::{authenticator, duo, email, enforce_2fa_policy, webauthn, yubikey}, + two_factor::{authenticator, duo, duo_oidc, email, enforce_2fa_policy, webauthn, yubikey}, }, push::register_push_device, ApiResult, EmptyResult, JsonResult, }, - auth::{generate_organization_api_key_login_claims, ClientHeaders, ClientIp}, + auth::{generate_organization_api_key_login_claims, ClientHeaders, ClientIp, ClientType}, db::{models::*, DbConn}, error::MapResult, mail, util, CONFIG, @@ -48,7 +48,7 @@ async fn login(data: Form, client_header: ClientHeaders, mut conn: _check_is_some(&data.device_name, "device_name cannot be blank")?; _check_is_some(&data.device_type, "device_type cannot be blank")?; - _password_login(data, &mut user_uuid, &mut conn, &client_header.ip).await + _password_login(data, &mut user_uuid, &mut conn, &client_header.ip, client_header.client_type).await } "client_credentials" => { _check_is_some(&data.client_id, "client_id cannot be blank")?; @@ -140,6 +140,7 @@ async fn _password_login( user_uuid: &mut Option, conn: &mut DbConn, ip: &ClientIp, + client_type: ClientType, ) -> JsonResult { // Validate scope let scope = data.scope.as_ref().unwrap(); @@ -250,7 +251,7 @@ async fn _password_login( let (mut device, new_device) = get_device(&data, conn, &user).await; - let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, conn).await?; + let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, &client_type, conn).await?; if CONFIG.mail_enabled() && new_device { if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device.name).await { @@ -485,6 +486,7 @@ async fn twofactor_auth( data: &ConnectData, device: &mut Device, ip: &ClientIp, + client_type: &ClientType, conn: &mut DbConn, ) -> ApiResult> { let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await; @@ -502,7 +504,7 @@ async fn twofactor_auth( let twofactor_code = match data.two_factor_token { Some(ref code) => code, - None => err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, conn).await?, "2FA token not provided"), + None => err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, client_type, conn).await?, "2FA token not provided"), }; let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled); @@ -518,8 +520,15 @@ async fn twofactor_auth( } Some(TwoFactorType::Webauthn) => webauthn::validate_webauthn_login(&user.uuid, twofactor_code, conn).await?, Some(TwoFactorType::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?, - Some(TwoFactorType::Duo) => { - duo::validate_duo_login(data.username.as_ref().unwrap().trim(), twofactor_code, conn).await? + Some(TwoFactorType::Duo | TwoFactorType::OrganizationDuo) => { + match CONFIG.duo_use_iframe() { + true => { + duo::validate_duo_login(data.username.as_ref().unwrap().trim(), twofactor_code, conn).await? + } + false => { + duo_oidc::validate_duo_login(data.username.as_ref().unwrap().trim(), twofactor_code, client_type, conn).await? + } + } } Some(TwoFactorType::Email) => { email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, conn).await? @@ -532,7 +541,7 @@ async fn twofactor_auth( } _ => { err_json!( - _json_err_twofactor(&twofactor_ids, &user.uuid, conn).await?, + _json_err_twofactor(&twofactor_ids, &user.uuid, client_type, conn).await?, "2FA Remember token not provided" ) } @@ -560,7 +569,7 @@ fn _selected_data(tf: Option) -> ApiResult { tf.map(|t| t.data).map_res("Two factor doesn't exist") } -async fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &mut DbConn) -> ApiResult { +async fn _json_err_twofactor(providers: &[i32], user_uuid: &str, client_type: &ClientType, conn: &mut DbConn) -> ApiResult { let mut result = json!({ "error" : "invalid_grant", "error_description" : "Two factor required.", @@ -582,18 +591,29 @@ async fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &mut DbCo result["TwoFactorProviders2"][provider.to_string()] = request.0; } - Some(TwoFactorType::Duo) => { + Some(TwoFactorType::Duo | TwoFactorType::OrganizationDuo) => { let email = match User::find_by_uuid(user_uuid, conn).await { Some(u) => u.email, None => err!("User does not exist"), }; - let (signature, host) = duo::generate_duo_signature(&email, conn).await?; + // Should we try to use the legacy iframe prompt? + match CONFIG.duo_use_iframe() { + true => { + let (signature, host) = duo::generate_duo_signature(&email, conn).await?; + result["TwoFactorProviders2"][provider.to_string()] = json!({ + "Host": host, + "Signature": signature, + }) + } + false => { + let auth_url = duo_oidc::get_duo_auth_url(&email, client_type, conn).await?; - result["TwoFactorProviders2"][provider.to_string()] = json!({ - "Host": host, - "Signature": signature, - }); + result["TwoFactorProviders2"][provider.to_string()] = json!({ + "AuthUrl": auth_url, + }) + } + } } Some(tf_type @ TwoFactorType::YubiKey) => { diff --git a/src/auth.rs b/src/auth.rs index 0f4a3076..5478decc 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -416,9 +416,47 @@ impl<'r> FromRequest<'r> for Host { } } +pub enum ClientType { + Unspecified = 0, + Web = 1, + Browser = 2, + Desktop = 3, + Mobile = 4, + Cli = 5, + DirectoryConnector = 6, +} + +impl ClientType { + pub fn as_str(&self) -> &'static str { + match self { + ClientType::Unspecified => "", + ClientType::Web => "web", + ClientType::Browser => "browser", + ClientType::Desktop => "desktop", + ClientType::Mobile => "mobile", + ClientType::Cli => "cli", + ClientType::DirectoryConnector => "connector", + } + } + + pub fn from_str(client_name: &str) -> ClientType { + match client_name { + "web" => ClientType::Web, + "browser" => ClientType::Browser, + "desktop" => ClientType::Desktop, + "mobile" => ClientType::Mobile, + "cli" => ClientType::Cli, + "connector" => ClientType::DirectoryConnector, + _ => ClientType::Unspecified, + } + } +} + + pub struct ClientHeaders { pub device_type: i32, pub ip: ClientIp, + pub client_type: ClientType, } #[rocket::async_trait] @@ -434,9 +472,13 @@ impl<'r> FromRequest<'r> for ClientHeaders { let device_type: i32 = request.headers().get_one("device-type").map(|d| d.parse().unwrap_or(14)).unwrap_or_else(|| 14); + let client_name = request.headers().get_one("Bitwarden-Client-Name").unwrap_or_else(|| ""); + let client_type: ClientType = ClientType::from_str(client_name); + Outcome::Success(ClientHeaders { device_type, ip, + client_type, }) } } diff --git a/src/config.rs b/src/config.rs index 1a41c9e8..b8fcebc6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -634,6 +634,8 @@ make_config! { duo: _enable_duo { /// Enabled _enable_duo: bool, true, def, true; + /// Attempt to use deprecated iframe-based Traditional Prompt (Duo WebSDK 2) + duo_use_iframe: bool, false, def, false; /// Integration Key duo_ikey: String, true, option; /// Secret Key