diff --git a/.env.template b/.env.template index c38368126c..b2197995d0 100644 --- a/.env.template +++ b/.env.template @@ -374,8 +374,6 @@ ## Set your Client ID and Client Key # SSO_CLIENT_ID=11111 # SSO_CLIENT_SECRET=AAAAAAAAAAAAAAAAAAAAAAAA -## Instead of displaying a Json error on auth failure, just redirect to the home page. -# SSO_AUTH_FAILURE_SILENT=false ## Optional SSO public key for JWT validation # SSO_KEY_FILEPATH=%DATA_FOLDER%/sso_key.pub.pem ## Optional Master password policy (minComplexity=[0-4]) diff --git a/SSO.md b/SSO.md index 6448ef2699..75afc97eac 100644 --- a/SSO.md +++ b/SSO.md @@ -19,7 +19,6 @@ The following configurations are available - $SSO_AUTHORITY/.well-known/openid-configuration should return the a json document: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse - `SSO_CLIENT_ID` : Client Id - `SSO_CLIENT_SECRET` : Client Secret - - `SSO_AUTH_FAILURE_SILENT`: Silently redirect to the home instead of displaying a JSON error. - `SSO_KEY_FILEPATH` : Optional public key to validate the JWT token (without it signature check will not be done). - `SSO_MASTER_PASSWORD_POLICY`: Optional Master password policy - `SSO_ROLES_ENABLED`: control if the mapping is done, default is `false` diff --git a/src/api/identity.rs b/src/api/identity.rs index 59e38dc38f..4b7b088d3d 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -803,33 +803,19 @@ fn prevalidate() -> JsonResult { #[get("/connect/oidc-signin?&", rank = 1)] fn oidcsignin(code: String, state: String, jar: &CookieJar<'_>) -> ApiResult { - let redirect_uri = jar - .get(&sso::COOKIE_NAME_REDIRECT.to_string()) - .map(|c| c.value().to_string()) - .unwrap_or(format!("{}/sso-connector.html", CONFIG.domain())); - - let redirect = CustomRedirect { - url: format!("{}?code={code}&state={state}", redirect_uri), - headers: vec![], - }; - - Ok(redirect) + Ok(sso::format_bitwarden_redirect(&code, &state, jar)) } -// No good way to display the error -// Bitwarden client appear to only care for code and state -// cf: https://github.com/bitwarden/clients/blob/8e46ef1ae5be8b62b0d3d0b9d1b1c62088a04638/libs/angular/src/auth/components/sso.component.ts#L68C11-L68C23) -#[get("/connect/oidc-signin?&", rank = 2)] -fn oidcsignin_error(error: String, error_description: Option) -> ApiResult { - if CONFIG.sso_auth_failure_silent() { - warn!("SSO login failed with {error} and {:?}", error_description); - Ok(CustomRedirect { - url: format!("/#?error={error}"), - headers: vec![], - }) - } else { - err!(format!("SSO login failed with {error} and {:?}", error_description)); - } +// To display the error we wrap it as JWT token +#[get("/connect/oidc-signin?&&", rank = 2)] +fn oidcsignin_error( + error: String, + error_description: Option, + state: String, + jar: &CookieJar<'_>, +) -> ApiResult { + let as_token = sso::wrap_sso_errors(error, error_description); + Ok(sso::format_bitwarden_redirect(&as_token, &state, jar)) } #[derive(Debug, Clone, Default, FromForm)] diff --git a/src/auth.rs b/src/auth.rs index 22af16f608..4953f74a58 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -29,6 +29,7 @@ static JWT_INVITE_ISSUER: Lazy = Lazy::new(|| format!("{}|invite", CONFI static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: Lazy = Lazy::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin())); static JWT_SSOTOKEN_ISSUER: Lazy = Lazy::new(|| format!("{}|ssotoken", CONFIG.domain_origin())); +static JWT_SSO_ERROR_ISSUER: Lazy = Lazy::new(|| format!("{}|ssoerror", CONFIG.domain_origin())); static JWT_DELETE_ISSUER: Lazy = Lazy::new(|| format!("{}|delete", CONFIG.domain_origin())); static JWT_VERIFYEMAIL_ISSUER: Lazy = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin())); static JWT_ADMIN_ISSUER: Lazy = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin())); @@ -117,6 +118,10 @@ pub fn decode_file_download(token: &str) -> Result { decode_jwt(token, JWT_FILE_DOWNLOAD_ISSUER.to_string()) } +pub fn decode_sso_error(token: &str) -> Result { + decode_jwt(token, JWT_SSO_ERROR_ISSUER.to_string()) +} + #[derive(Debug, Serialize, Deserialize)] pub struct LoginJwtClaims { // Not before @@ -422,6 +427,28 @@ pub fn generate_send_claims(send_id: &str, file_id: &str) -> BasicJwtClaims { } } +#[derive(Debug, Serialize, Deserialize)] +pub struct SSOCodeErrorClaims { + // Expiration time + pub exp: i64, + // Issuer + pub iss: String, + + pub error: String, + pub error_description: Option, +} + +pub fn generate_sso_error_claims(error: String, error_description: Option) -> String { + let code_error = SSOCodeErrorClaims { + exp: (Utc::now().naive_utc() + Duration::minutes(2)).timestamp(), + iss: JWT_SSO_ERROR_ISSUER.to_string(), + error, + error_description, + }; + + encode_jwt(&code_error) +} + // // Bearer token authentication // diff --git a/src/config.rs b/src/config.rs index 6befe8636f..770c7d11fa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -619,8 +619,6 @@ make_config! { sso_client_id: String, true, def, String::new(); /// Client Key sso_client_secret: Pass, true, def, String::new(); - /// Silent redirect - sso_auth_failure_silent: bool, true, def, false; /// Authority Server sso_authority: String, true, def, String::new(); /// CallBack Path diff --git a/src/sso.rs b/src/sso.rs index 04374e261d..ba127f2a56 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -1,4 +1,5 @@ use chrono::Utc; +use rocket::http::CookieJar; use std::collections::HashMap; use std::sync::RwLock; use std::time::Duration; @@ -13,6 +14,7 @@ use openidconnect::{ AccessToken, AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IdToken, Nonce, OAuth2TokenResponse, RefreshToken, Scope, }; +use regex::Regex; use crate::{ api::core::organizations::CollectionData, @@ -22,6 +24,7 @@ use crate::{ business::organization_logic, db::models::{Device, EventType, Organization, SsoNonce, User, UserOrgType, UserOrganization}, db::DbConn, + util::CustomRedirect, CONFIG, }; @@ -35,6 +38,8 @@ static CLIENT_CACHE: RwLock> = RwLock::new(None); static SSO_JWT_VALIDATION: Lazy = Lazy::new(prepare_decoding); +static SSO_ERRORS_REGEX: Lazy = Lazy::new(|| Regex::new(r"^error_(.*)$").unwrap()); + // Will Panic if SSO is activated and a key file is present but we can't decode its content pub fn load_lazy() { Lazy::force(&SSO_JWT_VALIDATION); @@ -343,12 +348,47 @@ async fn retrieve_user_info(client: &CoreClient, access_token: AccessToken) -> A } } +// Wrap the errors in a JWT token to be able to pass it as an OpenID response `code` +pub fn wrap_sso_errors(error: String, error_description: Option) -> String { + format!("error_{}", auth::generate_sso_error_claims(error, error_description)) +} + +// Check if the code is not in fact errors +fn unwrap_sso_erors(code: &str) -> Option> { + SSO_ERRORS_REGEX.captures(code).and_then(|captures| captures.get(1).map(|ma| auth::decode_sso_error(ma.as_str()))) +} + +pub fn format_bitwarden_redirect(code: &str, state: &str, jar: &CookieJar<'_>) -> CustomRedirect { + let redirect_uri = jar + .get(&COOKIE_NAME_REDIRECT.to_string()) + .map(|c| c.value().to_string()) + .unwrap_or(format!("{}/sso-connector.html", CONFIG.domain())); + + CustomRedirect { + url: format!("{}?code={code}&state={state}", redirect_uri), + headers: vec![], + } +} + // 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(code: &String) -> ApiResult { + match unwrap_sso_erors(code) { + Some(Ok(auth::SSOCodeErrorClaims { + error, + error_description, + .. + })) => { + let description = error_description.unwrap_or(String::new()); + err!(format!("Failed to login: {}, {}", error, description)) + } + Some(Err(error)) => err!(format!("Failed to decode SSO error: {error}")), + None => (), + } + if let Some(authenticated_user) = AC_CACHE.get(code) { return Ok(UserInformation { email: authenticated_user.email,