mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2026-03-22 09:49:20 -07:00
SSO using OpenID Connect (#3899)
* Add SSO functionality using OpenID Connect Co-authored-by: Pablo Ovelleiro Corral <mail@pablo.tools> Co-authored-by: Stuart Heap <sheap13@gmail.com> Co-authored-by: Alex Moore <skiepp@my-dockerfarm.cloud> Co-authored-by: Brian Munro <brian.alexander.munro@gmail.com> Co-authored-by: Jacques B. <timshel@github.com> * Improvements and error handling * Stop rolling device token * Add playwright tests * Activate PKCE by default * Ensure result order when searching for sso_user * add SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION * Toggle SSO button in scss * Base64 encode state before sending it to providers * Prevent disabled User from SSO login * Review fixes * Remove unused UserOrganization.invited_by_email * Split SsoUser::find_by_identifier_or_email * api::Accounts::verify_password add the policy even if it's ignored * Disable signups if SSO_ONLY is activated * Add verifiedDate to organizations::get_org_domain_sso_details * Review fixes * Remove OrganizationId guard from get_master_password_policy * Add wrapper type OIDCCode OIDCState OIDCIdentifier * Membership::confirm_user_invitations fix and tests * Allow set-password only if account is unitialized * Review fixes * Prevent accepting another user invitation * Log password change event on SSO account creation * Unify master password policy resolution * Upgrade openidconnect to 4.0.0 * Revert "Remove unused UserOrganization.invited_by_email" This reverts commit 548e19995e141314af98a10d170ea7371f02fab4. * Process org enrollment in accounts::post_set_password * Improve tests * Pass the claim invited_by_email in case it was not in db * Add Slack configuration hints * Fix playwright tests * Skip broken tests * Add sso identifier in admin user panel * Remove duplicate expiration check, add a log * Augment mobile refresh_token validity * Rauthy configuration hints * Fix playwright tests * Playwright upgrade and conf improvement * Playwright tests improvements * 2FA email and device creation change * Fix and improve Playwright tests * Minor improvements * Fix enforceOnLogin org policies * Run playwright sso tests against correct db * PKCE should now work with Zitadel * Playwright upgrade maildev to use MailBuffer.expect * Upgrades playwright tests deps * Check email_verified in id_token and user_info * Add sso verified endpoint for v2025.6.0 * Fix playwright tests * Create a separate sso_client * Upgrade openidconnect to 4.0.1 * Server settings for login fields toggle * Use only css for login fields * Fix playwright test * Review fix * More review fix * Perform same checks when setting kdf --------- Co-authored-by: Felix Eckhofer <felix@eckhofer.com> Co-authored-by: Pablo Ovelleiro Corral <mail@pablo.tools> Co-authored-by: Stuart Heap <sheap13@gmail.com> Co-authored-by: Alex Moore <skiepp@my-dockerfarm.cloud> Co-authored-by: Brian Munro <brian.alexander.munro@gmail.com> Co-authored-by: Jacques B. <timshel@github.com> Co-authored-by: Timshel <timshel@480s>
This commit is contained in:
254
src/auth.rs
254
src/auth.rs
@@ -1,6 +1,5 @@
|
||||
// JWT Handling
|
||||
//
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header};
|
||||
use num_traits::FromPrimitive;
|
||||
use once_cell::sync::{Lazy, OnceCell};
|
||||
@@ -10,17 +9,24 @@ use serde::ser::Serialize;
|
||||
use std::{env, net::IpAddr};
|
||||
|
||||
use crate::{
|
||||
api::ApiResult,
|
||||
config::PathType,
|
||||
db::models::{
|
||||
AttachmentId, CipherId, CollectionId, DeviceId, EmergencyAccessId, MembershipId, OrgApiKeyId, OrganizationId,
|
||||
SendFileId, SendId, UserId,
|
||||
AttachmentId, CipherId, CollectionId, DeviceId, DeviceType, EmergencyAccessId, MembershipId, OrgApiKeyId,
|
||||
OrganizationId, SendFileId, SendId, UserId,
|
||||
},
|
||||
error::Error,
|
||||
sso, CONFIG,
|
||||
};
|
||||
use crate::{error::Error, CONFIG};
|
||||
|
||||
const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
|
||||
|
||||
pub static DEFAULT_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_hours(2).unwrap());
|
||||
// Limit when BitWarden consider the token as expired
|
||||
pub static BW_EXPIRATION: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_minutes(5).unwrap());
|
||||
|
||||
pub static DEFAULT_REFRESH_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_days(30).unwrap());
|
||||
pub static MOBILE_REFRESH_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_days(90).unwrap());
|
||||
pub static DEFAULT_ACCESS_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_hours(2).unwrap());
|
||||
static JWT_HEADER: Lazy<Header> = Lazy::new(|| Header::new(JWT_ALGORITHM));
|
||||
|
||||
pub static JWT_LOGIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|login", CONFIG.domain_origin()));
|
||||
@@ -85,7 +91,7 @@ pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_jwt<T: DeserializeOwned>(token: &str, issuer: String) -> Result<T, Error> {
|
||||
pub fn decode_jwt<T: DeserializeOwned>(token: &str, issuer: String) -> Result<T, Error> {
|
||||
let mut validation = jsonwebtoken::Validation::new(JWT_ALGORITHM);
|
||||
validation.leeway = 30; // 30 seconds
|
||||
validation.validate_exp = true;
|
||||
@@ -99,11 +105,15 @@ fn decode_jwt<T: DeserializeOwned>(token: &str, issuer: String) -> Result<T, Err
|
||||
ErrorKind::InvalidToken => err!("Token is invalid"),
|
||||
ErrorKind::InvalidIssuer => err!("Issuer is invalid"),
|
||||
ErrorKind::ExpiredSignature => err!("Token has expired"),
|
||||
_ => err!("Error decoding JWT"),
|
||||
_ => err!(format!("Error decoding JWT: {:?}", err)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_refresh(token: &str) -> Result<RefreshJwtClaims, Error> {
|
||||
decode_jwt(token, JWT_LOGIN_ISSUER.to_string())
|
||||
}
|
||||
|
||||
pub fn decode_login(token: &str) -> Result<LoginJwtClaims, Error> {
|
||||
decode_jwt(token, JWT_LOGIN_ISSUER.to_string())
|
||||
}
|
||||
@@ -186,6 +196,84 @@ pub struct LoginJwtClaims {
|
||||
pub amr: Vec<String>,
|
||||
}
|
||||
|
||||
impl LoginJwtClaims {
|
||||
pub fn new(
|
||||
device: &Device,
|
||||
user: &User,
|
||||
nbf: i64,
|
||||
exp: i64,
|
||||
scope: Vec<String>,
|
||||
client_id: Option<String>,
|
||||
now: DateTime<Utc>,
|
||||
) -> Self {
|
||||
// ---
|
||||
// Disabled these keys to be added to the JWT since they could cause the JWT to get too large
|
||||
// Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients
|
||||
// Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out
|
||||
// ---
|
||||
// fn arg: orgs: Vec<super::UserOrganization>,
|
||||
// ---
|
||||
// let orgowner: Vec<_> = orgs.iter().filter(|o| o.atype == 0).map(|o| o.org_uuid.clone()).collect();
|
||||
// let orgadmin: Vec<_> = orgs.iter().filter(|o| o.atype == 1).map(|o| o.org_uuid.clone()).collect();
|
||||
// let orguser: Vec<_> = orgs.iter().filter(|o| o.atype == 2).map(|o| o.org_uuid.clone()).collect();
|
||||
// let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect();
|
||||
|
||||
if exp <= (now + *BW_EXPIRATION).timestamp() {
|
||||
warn!("Raise access_token lifetime to more than 5min.")
|
||||
}
|
||||
|
||||
// Create the JWT claims struct, to send to the client
|
||||
Self {
|
||||
nbf,
|
||||
exp,
|
||||
iss: JWT_LOGIN_ISSUER.to_string(),
|
||||
sub: user.uuid.clone(),
|
||||
premium: true,
|
||||
name: user.name.clone(),
|
||||
email: user.email.clone(),
|
||||
email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(),
|
||||
|
||||
// ---
|
||||
// Disabled these keys to be added to the JWT since they could cause the JWT to get too large
|
||||
// Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients
|
||||
// Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out
|
||||
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
|
||||
// ---
|
||||
// orgowner,
|
||||
// orgadmin,
|
||||
// orguser,
|
||||
// orgmanager,
|
||||
sstamp: user.security_stamp.clone(),
|
||||
device: device.uuid.clone(),
|
||||
devicetype: DeviceType::from_i32(device.atype).to_string(),
|
||||
client_id: client_id.unwrap_or("undefined".to_string()),
|
||||
scope,
|
||||
amr: vec!["Application".into()],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default(device: &Device, user: &User, auth_method: &AuthMethod, client_id: Option<String>) -> Self {
|
||||
let time_now = Utc::now();
|
||||
Self::new(
|
||||
device,
|
||||
user,
|
||||
time_now.timestamp(),
|
||||
(time_now + *DEFAULT_ACCESS_VALIDITY).timestamp(),
|
||||
auth_method.scope_vec(),
|
||||
client_id,
|
||||
time_now,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn token(&self) -> String {
|
||||
encode_jwt(&self)
|
||||
}
|
||||
|
||||
pub fn expires_in(&self) -> i64 {
|
||||
self.exp - Utc::now().timestamp()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct InviteJwtClaims {
|
||||
// Not before
|
||||
@@ -1001,3 +1089,153 @@ impl<'r> FromRequest<'r> for ClientVersion {
|
||||
Outcome::Success(ClientVersion(version))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthMethod {
|
||||
OrgApiKey,
|
||||
Password,
|
||||
Sso,
|
||||
UserApiKey,
|
||||
}
|
||||
|
||||
impl AuthMethod {
|
||||
pub fn scope(&self) -> String {
|
||||
match self {
|
||||
AuthMethod::OrgApiKey => "api.organization".to_string(),
|
||||
AuthMethod::Password => "api offline_access".to_string(),
|
||||
AuthMethod::Sso => "api offline_access".to_string(),
|
||||
AuthMethod::UserApiKey => "api".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scope_vec(&self) -> Vec<String> {
|
||||
self.scope().split_whitespace().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
pub fn check_scope(&self, scope: Option<&String>) -> ApiResult<String> {
|
||||
let method_scope = self.scope();
|
||||
match scope {
|
||||
None => err!("Missing scope"),
|
||||
Some(scope) if scope == &method_scope => Ok(method_scope),
|
||||
Some(scope) => err!(format!("Scope ({scope}) not supported")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum TokenWrapper {
|
||||
Access(String),
|
||||
Refresh(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RefreshJwtClaims {
|
||||
// Not before
|
||||
pub nbf: i64,
|
||||
// Expiration time
|
||||
pub exp: i64,
|
||||
// Issuer
|
||||
pub iss: String,
|
||||
// Subject
|
||||
pub sub: AuthMethod,
|
||||
|
||||
pub device_token: String,
|
||||
|
||||
pub token: Option<TokenWrapper>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AuthTokens {
|
||||
pub refresh_claims: RefreshJwtClaims,
|
||||
pub access_claims: LoginJwtClaims,
|
||||
}
|
||||
|
||||
impl AuthTokens {
|
||||
pub fn refresh_token(&self) -> String {
|
||||
encode_jwt(&self.refresh_claims)
|
||||
}
|
||||
|
||||
pub fn access_token(&self) -> String {
|
||||
self.access_claims.token()
|
||||
}
|
||||
|
||||
pub fn expires_in(&self) -> i64 {
|
||||
self.access_claims.expires_in()
|
||||
}
|
||||
|
||||
pub fn scope(&self) -> String {
|
||||
self.refresh_claims.sub.scope()
|
||||
}
|
||||
|
||||
// Create refresh_token and access_token with default validity
|
||||
pub fn new(device: &Device, user: &User, sub: AuthMethod, client_id: Option<String>) -> Self {
|
||||
let time_now = Utc::now();
|
||||
|
||||
let access_claims = LoginJwtClaims::default(device, user, &sub, client_id);
|
||||
|
||||
let validity = if DeviceType::is_mobile(&device.atype) {
|
||||
*MOBILE_REFRESH_VALIDITY
|
||||
} else {
|
||||
*DEFAULT_REFRESH_VALIDITY
|
||||
};
|
||||
|
||||
let refresh_claims = RefreshJwtClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: (time_now + validity).timestamp(),
|
||||
iss: JWT_LOGIN_ISSUER.to_string(),
|
||||
sub,
|
||||
device_token: device.refresh_token.clone(),
|
||||
token: None,
|
||||
};
|
||||
|
||||
Self {
|
||||
refresh_claims,
|
||||
access_claims,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh_tokens(
|
||||
ip: &ClientIp,
|
||||
refresh_token: &str,
|
||||
client_id: Option<String>,
|
||||
conn: &mut DbConn,
|
||||
) -> ApiResult<(Device, AuthTokens)> {
|
||||
let refresh_claims = match decode_refresh(refresh_token) {
|
||||
Err(err) => {
|
||||
debug!("Failed to decode {} refresh_token: {refresh_token}", ip.ip);
|
||||
err_silent!(format!("Impossible to read refresh_token: {}", err.message()))
|
||||
}
|
||||
Ok(claims) => claims,
|
||||
};
|
||||
|
||||
// Get device by refresh token
|
||||
let mut device = match Device::find_by_refresh_token(&refresh_claims.device_token, conn).await {
|
||||
None => err!("Invalid refresh token"),
|
||||
Some(device) => device,
|
||||
};
|
||||
|
||||
// Save to update `updated_at`.
|
||||
device.save(conn).await?;
|
||||
|
||||
let user = match User::find_by_uuid(&device.user_uuid, conn).await {
|
||||
None => err!("Impossible to find user"),
|
||||
Some(user) => user,
|
||||
};
|
||||
|
||||
let auth_tokens = match refresh_claims.sub {
|
||||
AuthMethod::Sso if CONFIG.sso_enabled() && CONFIG.sso_auth_only_not_session() => {
|
||||
AuthTokens::new(&device, &user, refresh_claims.sub, client_id)
|
||||
}
|
||||
AuthMethod::Sso if CONFIG.sso_enabled() => {
|
||||
sso::exchange_refresh_token(&device, &user, client_id, refresh_claims).await?
|
||||
}
|
||||
AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"),
|
||||
AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"),
|
||||
AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub, client_id),
|
||||
_ => err!("Invalid auth method, cannot refresh token"),
|
||||
};
|
||||
|
||||
Ok((device, auth_tokens))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user