diff --git a/.env.template b/.env.template index 2d5c5d8e..d74fadc5 100644 --- a/.env.template +++ b/.env.template @@ -566,6 +566,15 @@ ## 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: false (never), true (always), auto (only when a configured MFA ACR/AMR claim matches). See docs/SSO-2FA.md. +# SSO_2FA_SKIP=false + +## Exact, issuer-specific OIDC ACR value to request and accept as MFA in auto mode. Leave blank to disable ACR matching. +# SSO_2FA_ACR= + +## In auto mode, comma-separated exact OIDC AMR values accepted as MFA (otp requires pwd too). 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/docs/SSO-2FA.md b/docs/SSO-2FA.md new file mode 100644 index 00000000..4ae42830 --- /dev/null +++ b/docs/SSO-2FA.md @@ -0,0 +1,55 @@ +# Skipping Vaultwarden 2FA for SSO + +Vaultwarden can skip its own 2FA only when the trusted OpenID Connect provider reports that MFA was completed. Social login alone does not prove MFA: an account may have MFA disabled, or the broker may reuse an existing session without performing another factor. + +Configure claims for your provider and verify its signed ID token before enabling automatic skipping: + +```ini +SSO_2FA_SKIP=auto +SSO_2FA_AMR=mfa,otp,fido2,webauthn,hwk +# Optional exact, issuer-specific ACR value; Vaultwarden requests and matches it. +SSO_2FA_ACR= +``` + +`auto` matches `acr` exactly against `SSO_2FA_ACR` or configured `amr` entries exactly. Values are case-sensitive. An ACR request is a request, not proof the provider fulfilled the requested assurance. + +The default AMR list includes the explicit `mfa` signal, OTP paired with `pwd`, and `fido2`, `webauthn`, or `hwk` for passwordless/security-key sign-ins. OTP is a common second factor when combined with a password; Vaultwarden therefore accepts `otp` only when `pwd` is also present in the ID token. A passkey/security-key method may be strong passwordless authentication, but these generic AMR names alone do not guarantee user verification, phishing resistance, or a particular provider policy. Use them only when your trusted IdP emits them exclusively for the strong authentication you intend to accept. Set `SSO_2FA_AMR` to a stricter list or blank to disable AMR matching. `SSO_2FA_SKIP=false` always requires Vaultwarden 2FA; `SSO_2FA_SKIP=true` bypasses it for every SSO login and should be used only as a deliberate trust decision. + +## Provider setup + +### Authentik + +Authentik can broker social sources ([setup](https://docs.goauthentik.io/users-sources/sources/social-logins/)). Require an Authentik MFA stage after the social login in the authentication flow used by the Vaultwarden application. Authentik's ID-token implementation includes `mfa` in `amr` when the authentication event records MFA devices ([implementation](https://github.com/goauthentik/authentik/blob/95764ad89663f8343c82b13de4fa97c49e8a6b23/authentik/providers/oauth2/id_token.py#L109-L123)). Confirm that the actual ID token for both local and social sign-ins contains `mfa` only after the enforced MFA stage. Do not assume upstream Google, Microsoft, or other social MFA is propagated. + +### Keycloak + +Keycloak can broker social and other identity providers. Require MFA in the browser authentication flow for the Vaultwarden client, including brokered logins. Configure the AMR protocol mapper so the `mfa` reference is emitted only by a successful execution that enforces MFA ([authentication flow AMR mapping](https://github.com/keycloak/keycloak/blob/d4d55541da034a55a785c09922fcd62e51cd5bde/docs/documentation/server_admin/topics/authentication/flows.adoc#L109-L115)). Confirm the resulting ID token. + +Keycloak's broker `identity_provider` session note can be mapped to a separate custom claim to identify the upstream provider ([broker session data](https://github.com/keycloak/keycloak/blob/d4d55541da034a55a785c09922fcd62e51cd5bde/docs/documentation/server_admin/topics/identity-broker/session-data.adoc#L2-L12)). Use that claim for provenance only; it must not satisfy Vaultwarden's MFA check. + +### Authelia + +Authelia can act as an OIDC provider and apply a client's `two_factor` authorization policy ([client configuration](https://www.authelia.com/configuration/identity-providers/openid-connect/clients/)): + +```yaml +identity_providers: + oidc: + clients: + - client_id: vaultwarden + # Other required client settings... + authorization_policy: 'two_factor' +``` + +Authelia includes `mfa` in `amr` when its factor calculation confirms multiple factors; its OIDC integration test expects this value after two-factor authentication ([AMR implementation](https://github.com/authelia/authelia/blob/41f3b1ab5e97f2fabeacd75a65daf83b1f89d6eb/internal/authorization/amr.go#L74-L87), [OIDC test](https://github.com/authelia/authelia/blob/41f3b1ab5e97f2fabeacd75a65daf83b1f89d6eb/internal/suites/scenario_oidc_test.go#L95-L128)). Require `two_factor`, then confirm the deployed version's ID token before setting `SSO_2FA_AMR=mfa`. Do not use ACR for this: the reviewed integration test returns an empty `acr`. Authelia's OIDC provider is currently beta and does not support the Relying Party role, so it cannot broker upstream social logins. + +## Social providers and verification + +Direct social providers such as Google or Microsoft may not expose a stable MFA claim to Vaultwarden. When using a broker, do not infer MFA from the social source, a persistent user attribute, or an unverified upstream claim. Require MFA at the broker for the current authentication, or keep Vaultwarden 2FA enabled. + +Inspect the **ID token** produced by the exact login flow and verify: + +- The signed token is issued by the configured, trusted issuer and is intended for the Vaultwarden client. +- `amr` contains `mfa`, or `acr` has the exact configured value, only after the enforced MFA policy succeeds. +- Social/provider provenance, if emitted, is separate from the MFA claim and does not by itself suppress local 2FA. + +Avoid sharing ID tokens or enabling token-debug logging in production; tokens contain sensitive authentication data. For OIDC claim semantics, see [OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html#IDToken) and [RFC 8176](https://www.rfc-editor.org/rfc/rfc8176.html#section-2). diff --git a/src/api/identity.rs b/src/api/identity.rs index 4608ed77..527e9dba 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -336,7 +336,11 @@ 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 twofactor_token = if user_infos.should_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 a565a93e..f0bc26b9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -875,6 +875,12 @@ make_config! { sso_auth_only_not_session: bool, true, def, false; /// Client cache for discovery endpoint. |> Duration in seconds (0 to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache sso_client_cache_expiration: u32, true, def, 0; + /// Skip 2FA for SSO/social login |> `false` keeps Vaultwarden 2FA, `true` always skips it, `auto` skips it only when a configured MFA AMR or ACR claim matches + sso_2fa_skip: String, true, def, "false".to_owned(); + /// SSO 2FA ACR |> Exact issuer-specific ACR value requested and accepted as MFA for `SSO_2FA_SKIP=auto` + sso_2fa_acr: String, true, def, String::new(); + /// SSO 2FA AMR values |> Comma-separated exact AMR values accepted as MFA for `SSO_2FA_SKIP=auto`; `otp` also requires `pwd` + 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; }, @@ -1165,6 +1171,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") @@ -1785,6 +1795,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..dcc70595 100644 --- a/src/db/models/sso_auth.rs +++ b/src/db/models/sso_auth.rs @@ -10,6 +10,7 @@ use diesel::{ }; use crate::{ + CONFIG, api::EmptyResult, db::{DbConn, DbPool, schema::sso_auth}, error::MapResult, @@ -35,10 +36,131 @@ pub struct OIDCAuthenticatedUser { pub email: String, pub email_verified: Option, pub user_name: Option, + pub acr: Option, + pub amr: Option>, +} + +impl OIDCAuthenticatedUser { + pub fn should_skip_2fa(&self) -> bool { + let skip_mode = CONFIG.sso_2fa_skip(); + let acr = CONFIG.sso_2fa_acr(); + let amr_values = CONFIG.sso_2fa_amr_vec(); + + Self::should_skip_2fa_with(&skip_mode, self.acr.as_deref(), self.amr.as_deref(), &acr, &amr_values) + } + + fn should_skip_2fa_with( + skip_mode: &str, + user_acr: Option<&str>, + user_amr: Option<&[String]>, + configured_acr: &str, + configured_amr: &[String], + ) -> bool { + match skip_mode { + "true" => true, + "auto" => { + (!configured_acr.is_empty() && user_acr == Some(configured_acr)) + || user_amr.is_some_and(|methods| { + methods.iter().any(|method| { + configured_amr.contains(method) + && (method != "otp" || methods.iter().any(|other| other == "pwd")) + }) + }) + } + _ => false, + } + } } impl_FromToSqlText!(OIDCAuthenticatedUser); +#[cfg(test)] +mod tests { + use super::OIDCAuthenticatedUser; + + #[test] + fn two_factor_skip_modes_are_respected() { + assert!(!OIDCAuthenticatedUser::should_skip_2fa_with("false", None, None, "mfa", &[])); + assert!(OIDCAuthenticatedUser::should_skip_2fa_with("true", None, None, "", &[])); + assert!(!OIDCAuthenticatedUser::should_skip_2fa_with("invalid", Some("mfa"), None, "mfa", &[])); + } + + #[test] + fn auto_mode_matches_configured_acr_exactly() { + assert!(OIDCAuthenticatedUser::should_skip_2fa_with( + "auto", + Some("urn:example:mfa"), + None, + "urn:example:mfa", + &[] + )); + assert!(!OIDCAuthenticatedUser::should_skip_2fa_with( + "auto", + Some("urn:example:loa:2"), + None, + "urn:example:mfa", + &[] + )); + assert!(!OIDCAuthenticatedUser::should_skip_2fa_with( + "auto", + Some("URN:EXAMPLE:MFA"), + None, + "urn:example:mfa", + &[] + )); + } + + #[test] + fn auto_mode_matches_configured_amr_exactly() { + let accepted_amr = vec!["mfa".to_owned()]; + let otp_only_amr = vec!["otp".to_owned()]; + let password_and_otp_amr = vec!["pwd".to_owned(), "otp".to_owned()]; + + assert!(OIDCAuthenticatedUser::should_skip_2fa_with("auto", None, Some(&accepted_amr), "", &accepted_amr)); + assert!(!OIDCAuthenticatedUser::should_skip_2fa_with( + "auto", + None, + Some(&otp_only_amr), + "", + &["otp".to_owned()] + )); + assert!(OIDCAuthenticatedUser::should_skip_2fa_with( + "auto", + None, + Some(&password_and_otp_amr), + "", + &["otp".to_owned()] + )); + assert!(!OIDCAuthenticatedUser::should_skip_2fa_with("auto", None, None, "", &accepted_amr)); + } + + #[test] + fn auto_mode_matches_configured_passwordless_methods() { + let passkey_amr = vec!["webauthn".to_owned()]; + + assert!(OIDCAuthenticatedUser::should_skip_2fa_with( + "auto", + None, + Some(&passkey_amr), + "", + &["fido2".to_owned(), "webauthn".to_owned(), "hwk".to_owned()] + )); + } + + #[test] + fn empty_configured_claims_do_not_skip_two_factor() { + let returned_amr = vec!["mfa".to_owned()]; + + assert!(!OIDCAuthenticatedUser::should_skip_2fa_with( + "auto", + Some("urn:example:mfa"), + Some(&returned_amr), + "", + &[] + )); + } +} + #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] #[diesel(table_name = sso_auth)] #[diesel(treat_none_as_null = true)] diff --git a/src/sso.rs b/src/sso.rs index baa17492..df0f9798 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -307,6 +307,8 @@ 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()), }; if CONFIG.sso_debug_tokens() { diff --git a/src/sso_client.rs b/src/sso_client.rs index 246e5678..92bb7ba1 100644 --- a/src/sso_client.rs +++ b/src/sso_client.rs @@ -1,11 +1,11 @@ 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, - EndpointNotSet, EndpointSet, HttpClientError, HttpRequest, HttpResponse, IdTokenClaims, IdTokenFields, Nonce, - OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RefreshToken, ResponseType, Scope, StandardErrorResponse, - StandardTokenResponse, + 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, core::{ CoreAuthDisplay, CoreAuthPrompt, CoreClient, CoreClientAuthMethod, CoreErrorResponseType, CoreGenderClaim, CoreIdTokenVerifier, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, @@ -202,6 +202,11 @@ impl Client { .add_scopes(scopes) .add_extra_params(CONFIG.sso_authorize_extra_params_vec()); + let sso_2fa_acr = CONFIG.sso_2fa_acr(); + if CONFIG.sso_2fa_skip() == "auto" && !sso_2fa_acr.is_empty() { + auth_req = auth_req.add_auth_context_value(AuthenticationContextClass::new(sso_2fa_acr)); + } + if CONFIG.sso_pkce() { auth_req = auth_req .add_extra_param::<&str, String>("code_challenge", client_challenge.clone().into())