Browse Source

Implement ACR and AMR configuration for SSO 2FA skipping; update documentation

Signed-off-by: Kowalski Dragon (kowalski7cc) <kowalski7cc@users.noreply.github.com>
pull/7749/head
Kowalski Dragon (kowalski7cc) 5 days ago
parent
commit
0da829d597
No known key found for this signature in database GPG Key ID: C4E819BD2BC6233E
  1. 7
      .env.template
  2. 55
      docs/SSO-2FA.md
  3. 11
      src/api/identity.rs
  4. 6
      src/config.rs
  5. 120
      src/db/models/sso_auth.rs
  6. 6
      src/sso.rs
  7. 12
      src/sso_client.rs

7
.env.template

@ -557,10 +557,13 @@
## 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)
## 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
## In auto mode, comma-separated OIDC AMR values that indicate MFA. Set blank to disable AMR matching.
## 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

55
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).

11
src/api/identity.rs

@ -336,16 +336,7 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
Some((mut user, sso_user)) => {
let mut device = get_device(&data, conn, &user).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 {
let twofactor_token = if user_infos.should_skip_2fa() {
None
} else {
twofactor_auth(&mut user, &data, &mut device, ip, conn).await?

6
src/config.rs

@ -867,9 +867,11 @@ 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 the IdP returns an MFA AMR or ACR claim
/// 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 AMR values |> Comma-separated AMR values that satisfy `SSO_2FA_SKIP=auto`
/// 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;

120
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,
@ -39,8 +40,127 @@ pub struct OIDCAuthenticatedUser {
pub amr: Option<Vec<String>>,
}
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)]

6
src/sso.rs

@ -16,8 +16,6 @@ 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<String> = LazyLock::new(|| format!("{}|sso", CONFIG.domain_origin()));
@ -310,9 +308,7 @@ pub async fn exchange_code(
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()),
amr: id_claims.auth_method_refs().map(|amr| amr.iter().map(|method| method.as_str().to_string()).collect()),
};
if CONFIG.sso_debug_tokens() {

12
src/sso_client.rs

@ -3,10 +3,9 @@ use std::{borrow::Cow, collections::HashSet, future::Future, pin::Pin, sync::Laz
use openidconnect::{
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,
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,
@ -203,8 +202,9 @@ 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()));
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() {

Loading…
Cancel
Save