Skip to content

Commit

Permalink
Wrap sso errors as a custom JWT and propagate it as OIDC code
Browse files Browse the repository at this point in the history
  • Loading branch information
Timshel committed Jan 18, 2024
1 parent e718547 commit e095561
Show file tree
Hide file tree
Showing 6 changed files with 78 additions and 30 deletions.
2 changes: 0 additions & 2 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
1 change: 0 additions & 1 deletion SSO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
36 changes: 11 additions & 25 deletions src/api/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -803,33 +803,19 @@ fn prevalidate() -> JsonResult {

#[get("/connect/oidc-signin?<code>&<state>", rank = 1)]
fn oidcsignin(code: String, state: String, jar: &CookieJar<'_>) -> ApiResult<CustomRedirect> {
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?<error>&<error_description>", rank = 2)]
fn oidcsignin_error(error: String, error_description: Option<String>) -> ApiResult<CustomRedirect> {
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?<error>&<error_description>&<state>", rank = 2)]
fn oidcsignin_error(
error: String,
error_description: Option<String>,
state: String,
jar: &CookieJar<'_>,
) -> ApiResult<CustomRedirect> {
let as_token = sso::wrap_sso_errors(error, error_description);
Ok(sso::format_bitwarden_redirect(&as_token, &state, jar))
}

#[derive(Debug, Clone, Default, FromForm)]
Expand Down
27 changes: 27 additions & 0 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ static JWT_INVITE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|invite", CONFI
static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: Lazy<String> =
Lazy::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin()));
static JWT_SSOTOKEN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|ssotoken", CONFIG.domain_origin()));
static JWT_SSO_ERROR_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|ssoerror", CONFIG.domain_origin()));
static JWT_DELETE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|delete", CONFIG.domain_origin()));
static JWT_VERIFYEMAIL_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin()));
static JWT_ADMIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin()));
Expand Down Expand Up @@ -117,6 +118,10 @@ pub fn decode_file_download(token: &str) -> Result<FileDownloadClaims, Error> {
decode_jwt(token, JWT_FILE_DOWNLOAD_ISSUER.to_string())
}

pub fn decode_sso_error(token: &str) -> Result<SSOCodeErrorClaims, Error> {
decode_jwt(token, JWT_SSO_ERROR_ISSUER.to_string())
}

#[derive(Debug, Serialize, Deserialize)]
pub struct LoginJwtClaims {
// Not before
Expand Down Expand Up @@ -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<String>,
}

pub fn generate_sso_error_claims(error: String, error_description: Option<String>) -> 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
//
Expand Down
2 changes: 0 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions src/sso.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use chrono::Utc;
use rocket::http::CookieJar;
use std::collections::HashMap;
use std::sync::RwLock;
use std::time::Duration;
Expand All @@ -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,
Expand All @@ -22,6 +24,7 @@ use crate::{
business::organization_logic,
db::models::{Device, EventType, Organization, SsoNonce, User, UserOrgType, UserOrganization},
db::DbConn,
util::CustomRedirect,
CONFIG,
};

Expand All @@ -35,6 +38,8 @@ static CLIENT_CACHE: RwLock<Option<CoreClient>> = RwLock::new(None);

static SSO_JWT_VALIDATION: Lazy<Decoding> = Lazy::new(prepare_decoding);

static SSO_ERRORS_REGEX: Lazy<Regex> = 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);
Expand Down Expand Up @@ -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>) -> 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<Result<auth::SSOCodeErrorClaims, crate::error::Error>> {
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<UserInformation> {
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,
Expand Down

0 comments on commit e095561

Please sign in to comment.