use chrono::Utc; use derive_more::{AsRef, Deref, Display, From}; use regex::Regex; use std::time::Duration; use url::Url; use mini_moka::sync::Cache; use once_cell::sync::Lazy; use crate::{ api::ApiResult, auth, auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, db::{ models::{Device, SsoNonce, User}, DbConn, }, sso_client::Client, CONFIG, }; pub static FAKE_IDENTIFIER: &str = "Vaultwarden"; static AC_CACHE: Lazy> = Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build()); static SSO_JWT_ISSUER: Lazy = Lazy::new(|| format!("{}|sso", CONFIG.domain_origin())); pub static NONCE_EXPIRATION: Lazy = Lazy::new(|| chrono::TimeDelta::try_minutes(10).unwrap()); #[derive( Clone, Debug, Default, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize, AsRef, Deref, Display, From, )] #[deref(forward)] #[from(forward)] pub struct OIDCCode(String); #[derive( Clone, Debug, Default, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize, AsRef, Deref, Display, From, )] #[deref(forward)] #[from(forward)] pub struct OIDCState(String); #[derive(Debug, Serialize, Deserialize)] struct SsoTokenJwtClaims { // Not before pub nbf: i64, // Expiration time pub exp: i64, // Issuer pub iss: String, // Subject pub sub: String, } pub fn encode_ssotoken_claims() -> String { let time_now = Utc::now(); let claims = SsoTokenJwtClaims { nbf: time_now.timestamp(), exp: (time_now + chrono::TimeDelta::try_minutes(2).unwrap()).timestamp(), iss: SSO_JWT_ISSUER.to_string(), sub: "vaultwarden".to_string(), }; auth::encode_jwt(&claims) } #[derive(Debug, Serialize, Deserialize)] pub enum OIDCCodeWrapper { Ok { state: OIDCState, code: OIDCCode, }, Error { state: OIDCState, error: String, error_description: Option, }, } #[derive(Debug, Serialize, Deserialize)] struct OIDCCodeClaims { // Expiration time pub exp: i64, // Issuer pub iss: String, pub code: OIDCCodeWrapper, } pub fn encode_code_claims(code: OIDCCodeWrapper) -> String { let time_now = Utc::now(); let claims = OIDCCodeClaims { exp: (time_now + chrono::TimeDelta::try_minutes(5).unwrap()).timestamp(), iss: SSO_JWT_ISSUER.to_string(), code, }; auth::encode_jwt(&claims) } #[derive(Clone, Debug, Serialize, Deserialize)] struct BasicTokenClaims { iat: Option, nbf: Option, exp: i64, } impl BasicTokenClaims { fn nbf(&self) -> i64 { self.nbf.or(self.iat).unwrap_or_else(|| Utc::now().timestamp()) } } fn decode_token_claims(token_name: &str, token: &str) -> ApiResult { let mut validation = jsonwebtoken::Validation::default(); validation.set_issuer(&[CONFIG.sso_authority()]); validation.insecure_disable_signature_validation(); validation.validate_aud = false; match jsonwebtoken::decode(token, &jsonwebtoken::DecodingKey::from_secret(&[]), &validation) { Ok(btc) => Ok(btc.claims), Err(err) => err_silent!(format!("Failed to decode basic token claims from {token_name}: {err}")), } } pub fn decode_state(base64_state: String) -> ApiResult { let state = match data_encoding::BASE64.decode(base64_state.as_bytes()) { Ok(vec) => match String::from_utf8(vec) { Ok(valid) => OIDCState(valid), Err(_) => err!(format!("Invalid utf8 chars in {base64_state} after base64 decoding")), }, Err(_) => err!(format!("Failed to decode {base64_state} using base64")), }; Ok(state) } // The `nonce` allow to protect against replay attacks // redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/ApiClient.cs pub async fn authorize_url( state: OIDCState, client_id: &str, raw_redirect_uri: &str, mut conn: DbConn, ) -> ApiResult { let redirect_uri = match client_id { "web" | "browser" => format!("{}/sso-connector.html", CONFIG.domain()), "desktop" | "mobile" => "bitwarden://sso-callback".to_string(), "cli" => { let port_regex = Regex::new(r"^http://localhost:([0-9]{4})$").unwrap(); match port_regex.captures(raw_redirect_uri).and_then(|captures| captures.get(1).map(|c| c.as_str())) { Some(port) => format!("http://localhost:{port}"), None => err!("Failed to extract port number"), } } _ => err!(format!("Unsupported client {client_id}")), }; let (auth_url, nonce) = Client::authorize_url(state, redirect_uri).await?; nonce.save(&mut conn).await?; Ok(auth_url) } #[derive( Clone, Debug, Default, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize, AsRef, Deref, Display, From, )] #[deref(forward)] #[from(forward)] pub struct OIDCIdentifier(String); impl OIDCIdentifier { fn new(issuer: &str, subject: &str) -> Self { OIDCIdentifier(format!("{issuer}/{subject}")) } } #[derive(Clone, Debug)] pub struct AuthenticatedUser { pub refresh_token: Option, pub access_token: String, pub expires_in: Option, pub identifier: OIDCIdentifier, pub email: String, pub email_verified: Option, pub user_name: Option, } #[derive(Clone, Debug)] pub struct UserInformation { pub state: OIDCState, pub identifier: OIDCIdentifier, pub email: String, pub email_verified: Option, pub user_name: Option, } async fn decode_code_claims(code: &str, conn: &mut DbConn) -> ApiResult<(OIDCCode, OIDCState)> { match auth::decode_jwt::(code, SSO_JWT_ISSUER.to_string()) { Ok(code_claims) => match code_claims.code { OIDCCodeWrapper::Ok { state, code, } => Ok((code, state)), OIDCCodeWrapper::Error { state, error, error_description, } => { if let Err(err) = SsoNonce::delete(&state, conn).await { error!("Failed to delete database sso_nonce using {state}: {err}") } err!(format!( "SSO authorization failed: {error}, {}", error_description.as_ref().unwrap_or(&String::new()) )) } }, Err(err) => err!(format!("Failed to decode code wrapper: {err}")), } } // During the 2FA flow we will // - retrieve the user information and then only discover he needs 2FA. // - second time we will rely on the `AC_CACHE` since the `code` has already been exchanged. // The `nonce` will ensure that the user is authorized only once. // We return only the `UserInformation` to force calling `redeem` to obtain the `refresh_token`. pub async fn exchange_code(wrapped_code: &str, conn: &mut DbConn) -> ApiResult { use openidconnect::OAuth2TokenResponse; let (code, state) = decode_code_claims(wrapped_code, conn).await?; if let Some(authenticated_user) = AC_CACHE.get(&state) { return Ok(UserInformation { state, identifier: authenticated_user.identifier, email: authenticated_user.email, email_verified: authenticated_user.email_verified, user_name: authenticated_user.user_name, }); } let nonce = match SsoNonce::find(&state, conn).await { None => err!(format!("Invalid state cannot retrieve nonce")), Some(nonce) => nonce, }; let client = Client::cached().await?; let (token_response, id_claims) = client.exchange_code(code, nonce).await?; let user_info = client.user_info(token_response.access_token().to_owned()).await?; let email = match id_claims.email().or(user_info.email()) { None => err!("Neither id token nor userinfo contained an email"), Some(e) => e.to_string().to_lowercase(), }; let email_verified = id_claims.email_verified().or(user_info.email_verified()); let user_name = id_claims.preferred_username().map(|un| un.to_string()); let refresh_token = token_response.refresh_token().map(|t| t.secret()); if refresh_token.is_none() && CONFIG.sso_scopes_vec().contains(&"offline_access".to_string()) { error!("Scope offline_access is present but response contain no refresh_token"); } let identifier = OIDCIdentifier::new(id_claims.issuer(), id_claims.subject()); let authenticated_user = AuthenticatedUser { refresh_token: refresh_token.cloned(), access_token: token_response.access_token().secret().clone(), expires_in: token_response.expires_in(), identifier: identifier.clone(), email: email.clone(), email_verified, user_name: user_name.clone(), }; debug!("Authenticated user {authenticated_user:?}"); AC_CACHE.insert(state.clone(), authenticated_user); Ok(UserInformation { state, identifier, email, email_verified, user_name, }) } // User has passed 2FA flow we can delete `nonce` and clear the cache. pub async fn redeem(state: &OIDCState, conn: &mut DbConn) -> ApiResult { if let Err(err) = SsoNonce::delete(state, conn).await { error!("Failed to delete database sso_nonce using {state}: {err}") } if let Some(au) = AC_CACHE.get(state) { AC_CACHE.invalidate(state); Ok(au) } else { err!("Failed to retrieve user info from sso cache") } } // We always return a refresh_token (with no refresh_token some secrets are not displayed in the web front). // If there is no SSO refresh_token, we keep the access_token to be able to call user_info to check for validity pub fn create_auth_tokens( device: &Device, user: &User, client_id: Option, refresh_token: Option, access_token: String, expires_in: Option, ) -> ApiResult { if !CONFIG.sso_auth_only_not_session() { let now = Utc::now(); let (ap_nbf, ap_exp) = match (decode_token_claims("access_token", &access_token), expires_in) { (Ok(ap), _) => (ap.nbf(), ap.exp), (Err(_), Some(exp)) => (now.timestamp(), (now + exp).timestamp()), _ => err!("Non jwt access_token and empty expires_in"), }; let access_claims = auth::LoginJwtClaims::new(device, user, ap_nbf, ap_exp, AuthMethod::Sso.scope_vec(), client_id, now); _create_auth_tokens(device, refresh_token, access_claims, access_token) } else { Ok(AuthTokens::new(device, user, AuthMethod::Sso, client_id)) } } fn _create_auth_tokens( device: &Device, refresh_token: Option, access_claims: auth::LoginJwtClaims, access_token: String, ) -> ApiResult { let (nbf, exp, token) = if let Some(rt) = refresh_token { match decode_token_claims("refresh_token", &rt) { Err(_) => { let time_now = Utc::now(); let exp = (time_now + *DEFAULT_REFRESH_VALIDITY).timestamp(); debug!("Non jwt refresh_token (expiration set to {exp})"); (time_now.timestamp(), exp, TokenWrapper::Refresh(rt)) } Ok(refresh_payload) => { debug!("Refresh_payload: {refresh_payload:?}"); (refresh_payload.nbf(), refresh_payload.exp, TokenWrapper::Refresh(rt)) } } } else { debug!("No refresh_token present"); (access_claims.nbf, access_claims.exp, TokenWrapper::Access(access_token)) }; let refresh_claims = auth::RefreshJwtClaims { nbf, exp, iss: auth::JWT_LOGIN_ISSUER.to_string(), sub: AuthMethod::Sso, device_token: device.refresh_token.clone(), token: Some(token), }; Ok(AuthTokens { refresh_claims, access_claims, }) } // This endpoint is called in two case // - the session is close to expiration we will try to extend it // - the user is going to make an action and we check that the session is still valid pub async fn exchange_refresh_token( device: &Device, user: &User, client_id: Option, refresh_claims: auth::RefreshJwtClaims, ) -> ApiResult { let exp = refresh_claims.exp; match refresh_claims.token { Some(TokenWrapper::Refresh(refresh_token)) => { // Use new refresh_token if returned let (new_refresh_token, access_token, expires_in) = Client::exchange_refresh_token(refresh_token.clone()).await?; create_auth_tokens( device, user, client_id, new_refresh_token.or(Some(refresh_token)), access_token, expires_in, ) } Some(TokenWrapper::Access(access_token)) => { let now = Utc::now(); let exp_limit = (now + *BW_EXPIRATION).timestamp(); if exp < exp_limit { err_silent!("Access token is close to expiration but we have no refresh token") } Client::check_validity(access_token.clone()).await?; let access_claims = auth::LoginJwtClaims::new( device, user, now.timestamp(), exp, AuthMethod::Sso.scope_vec(), client_id, now, ); _create_auth_tokens(device, None, access_claims, access_token) } None => err!("No token present while in SSO"), } }