diff --git a/.env.template b/.env.template index 272c7738..1b4e44e9 100644 --- a/.env.template +++ b/.env.template @@ -549,6 +549,12 @@ ## Optional Master password policy (minComplexity=[0-4]), `enforceOnLogin` is not supported at the moment. # SSO_MASTER_PASSWORD_POLICY='{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}' +## Skip Vaultwarden 2FA for SSO/social login: false (never), true (always), auto (only when the IdP returns an MFA ACR/AMR claim) +# SSO_2FA_SKIP=false + +## In auto mode, comma-separated OIDC AMR values that indicate MFA. Set blank to disable AMR matching. +# SSO_2FA_AMR="mfa,otp,fido2,webauthn,hwk" + ## Use sso only for authentication not the session lifecycle # SSO_AUTH_ONLY_NOT_SESSION=false diff --git a/src/api/identity.rs b/src/api/identity.rs index b61258cf..5de0bd6d 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -335,7 +335,20 @@ async fn sso_login(data: ConnectData, user_id: &mut Option, conn: &DbCon Some((mut user, sso_user)) => { let mut device = get_device(&data, conn, &user).await?; - let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?; + let skip_2fa = match CONFIG.sso_2fa_skip().as_str() { + "true" => true, + "auto" => { + let amr_values = CONFIG.sso_2fa_amr_vec(); + user_infos.acr.as_deref() == Some(sso::SSO_2FA_ACR) + || user_infos.amr.as_ref().is_some_and(|amr| amr.iter().any(|method| amr_values.contains(method))) + } + _ => false, + }; + let twofactor_token = if skip_2fa { + None + } else { + twofactor_auth(&mut user, &data, &mut device, ip, conn).await? + }; if user.private_key.is_none() { // User was invited a stub was created diff --git a/src/config.rs b/src/config.rs index a56748f4..a23cbfed 100644 --- a/src/config.rs +++ b/src/config.rs @@ -846,6 +846,10 @@ make_config! { sso_auth_only_not_session: bool, true, def, false; /// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache sso_client_cache_expiration: u64, true, def, 0; + /// Skip 2FA for SSO/social login |> `false` keeps Vaultwarden 2FA, `true` always skips it, `auto` skips it only when the IdP returns an MFA AMR or ACR claim + sso_2fa_skip: String, true, def, "false".to_owned(); + /// SSO 2FA AMR values |> Comma-separated AMR values that satisfy `SSO_2FA_SKIP=auto` + sso_2fa_amr: String, true, def, "mfa,otp,fido2,webauthn,hwk".to_owned(); /// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required sso_debug_tokens: bool, true, def, false; }, @@ -1113,6 +1117,10 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?; } + if !matches!(cfg.sso_2fa_skip.as_str(), "false" | "true" | "auto") { + err!("`SSO_2FA_SKIP` must be one of: false, true, auto"); + } + if cfg._enable_yubico { if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() { err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support") @@ -1678,6 +1686,10 @@ impl Config { pub fn sso_authorize_extra_params_vec(&self) -> Vec<(String, String)> { url::form_urlencoded::parse(self.sso_authorize_extra_params().as_bytes()).into_owned().collect() } + + pub fn sso_2fa_amr_vec(&self) -> Vec { + self.sso_2fa_amr().split(',').map(str::trim).filter(|v| !v.is_empty()).map(str::to_owned).collect() + } } use handlebars::{ diff --git a/src/db/models/sso_auth.rs b/src/db/models/sso_auth.rs index 311e9bf9..958a85f1 100644 --- a/src/db/models/sso_auth.rs +++ b/src/db/models/sso_auth.rs @@ -35,6 +35,8 @@ pub struct OIDCAuthenticatedUser { pub email: String, pub email_verified: Option, pub user_name: Option, + pub acr: Option, + pub amr: Option>, } impl_FromToSqlText!(OIDCAuthenticatedUser); diff --git a/src/sso.rs b/src/sso.rs index 6b575a3e..4be736c4 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -16,6 +16,8 @@ use crate::{ sso_client::Client, }; +pub const SSO_2FA_ACR: &str = "2"; + pub static FAKE_SSO_IDENTIFIER: &str = "00000000-01DC-01DC-01DC-000000000000"; static SSO_JWT_ISSUER: LazyLock = LazyLock::new(|| format!("{}|sso", CONFIG.domain_origin())); @@ -307,6 +309,10 @@ pub async fn exchange_code( email: email.clone(), email_verified, user_name: user_name.clone(), + acr: id_claims.auth_context_ref().map(|acr| acr.as_str().to_string()), + amr: id_claims + .auth_method_refs() + .map(|amr| amr.iter().map(|method| method.as_str().to_string()).collect()), }; debug!("Authenticated user {authenticated_user:?}"); diff --git a/src/sso_client.rs b/src/sso_client.rs index a5dcaa83..a65adee3 100644 --- a/src/sso_client.rs +++ b/src/sso_client.rs @@ -1,8 +1,9 @@ use std::{borrow::Cow, collections::HashSet, future::Future, pin::Pin, sync::LazyLock, time::Duration}; use openidconnect::{ - AccessToken, AsyncHttpClient, AuthDisplay, AuthPrompt, AuthType, AuthenticationFlow, AuthorizationCode, - AuthorizationRequest, ClientId, ClientSecret, CsrfToken, EmptyAdditionalClaims, EmptyExtraTokenFields, + AccessToken, AsyncHttpClient, AuthDisplay, AuthPrompt, AuthType, AuthenticationContextClass, AuthenticationFlow, + AuthorizationCode, AuthorizationRequest, ClientId, ClientSecret, CsrfToken, EmptyAdditionalClaims, + EmptyExtraTokenFields, EndpointNotSet, EndpointSet, HttpClientError, HttpRequest, HttpResponse, IdTokenClaims, IdTokenFields, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RefreshToken, ResponseType, Scope, StandardErrorResponse, StandardTokenResponse, @@ -202,6 +203,10 @@ impl Client { .add_scopes(scopes) .add_extra_params(CONFIG.sso_authorize_extra_params_vec()); + if CONFIG.sso_2fa_skip() == "auto" { + auth_req = auth_req.add_auth_context_value(AuthenticationContextClass::new(crate::sso::SSO_2FA_ACR.to_owned())); + } + if CONFIG.sso_pkce() { auth_req = auth_req .add_extra_param::<&str, String>("code_challenge", client_challenge.clone().into())