Browse Source

Merge 6231d24374 into a2ad1dc7c3

pull/6205/merge
Timshel 6 days ago
committed by GitHub
parent
commit
51de19a3bf
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      .env.template
  2. 9
      migrations/mysql/2025-08-20-120000_sso_nonce_to_auth/down.sql
  3. 12
      migrations/mysql/2025-08-20-120000_sso_nonce_to_auth/up.sql
  4. 9
      migrations/postgresql/2025-08-20-120000_sso_nonce_to_auth/down.sql
  5. 12
      migrations/postgresql/2025-08-20-120000_sso_nonce_to_auth/up.sql
  6. 9
      migrations/sqlite/2025-08-20-120000_sso_nonce_to_auth/down.sql
  7. 12
      migrations/sqlite/2025-08-20-120000_sso_nonce_to_auth/up.sql
  8. 104
      src/api/identity.rs
  9. 4
      src/config.rs
  10. 4
      src/db/models/mod.rs
  11. 230
      src/db/models/sso_auth.rs
  12. 89
      src/db/models/sso_nonce.rs
  13. 7
      src/db/schemas/mysql/schema.rs
  14. 7
      src/db/schemas/postgresql/schema.rs
  15. 7
      src/db/schemas/sqlite/schema.rs
  16. 8
      src/main.rs
  17. 236
      src/sso.rs
  18. 39
      src/sso_client.rs

4
.env.template

@ -183,9 +183,9 @@
## Defaults to every minute. Set blank to disable this job. ## Defaults to every minute. Set blank to disable this job.
# DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *" # DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *"
# #
## Cron schedule of the job that cleans sso nonce from incomplete flow ## Cron schedule of the job that cleans sso auth from incomplete flow
## Defaults to daily (20 minutes after midnight). Set blank to disable this job. ## Defaults to daily (20 minutes after midnight). Set blank to disable this job.
# PURGE_INCOMPLETE_SSO_NONCE="0 20 0 * * *" # PURGE_INCOMPLETE_SSO_AUTH="0 20 0 * * *"
######################## ########################
### General settings ### ### General settings ###

9
migrations/mysql/2025-08-20-120000_sso_nonce_to_auth/down.sql

@ -0,0 +1,9 @@
DROP TABLE IF EXISTS sso_auth;
CREATE TABLE sso_nonce (
state VARCHAR(512) NOT NULL PRIMARY KEY,
nonce TEXT NOT NULL,
verifier TEXT,
redirect_uri TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);

12
migrations/mysql/2025-08-20-120000_sso_nonce_to_auth/up.sql

@ -0,0 +1,12 @@
DROP TABLE IF EXISTS sso_nonce;
CREATE TABLE sso_auth (
state VARCHAR(512) NOT NULL PRIMARY KEY,
client_challenge TEXT NOT NULL,
nonce TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
code_response JSON,
auth_response JSON,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
);

9
migrations/postgresql/2025-08-20-120000_sso_nonce_to_auth/down.sql

@ -0,0 +1,9 @@
DROP TABLE IF EXISTS sso_auth;
CREATE TABLE sso_nonce (
state TEXT NOT NULL PRIMARY KEY,
nonce TEXT NOT NULL,
verifier TEXT,
redirect_uri TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);

12
migrations/postgresql/2025-08-20-120000_sso_nonce_to_auth/up.sql

@ -0,0 +1,12 @@
DROP TABLE IF EXISTS sso_nonce;
CREATE TABLE sso_auth (
state TEXT NOT NULL PRIMARY KEY,
client_challenge TEXT NOT NULL,
nonce TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
code_response JSON,
auth_response JSON,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
);

9
migrations/sqlite/2025-08-20-120000_sso_nonce_to_auth/down.sql

@ -0,0 +1,9 @@
DROP TABLE IF EXISTS sso_auth;
CREATE TABLE sso_nonce (
state TEXT NOT NULL PRIMARY KEY,
nonce TEXT NOT NULL,
verifier TEXT,
redirect_uri TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

12
migrations/sqlite/2025-08-20-120000_sso_nonce_to_auth/up.sql

@ -0,0 +1,12 @@
DROP TABLE IF EXISTS sso_nonce;
CREATE TABLE sso_auth (
state TEXT NOT NULL PRIMARY KEY,
client_challenge TEXT NOT NULL,
nonce TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
code_response TEXT,
auth_response TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

104
src/api/identity.rs

@ -25,7 +25,7 @@ use crate::{
db::{models::*, DbConn}, db::{models::*, DbConn},
error::MapResult, error::MapResult,
mail, sso, mail, sso,
sso::{OIDCCode, OIDCState}, sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState},
util, CONFIG, util, CONFIG,
}; };
@ -86,6 +86,7 @@ async fn login(
"authorization_code" if CONFIG.sso_enabled() => { "authorization_code" if CONFIG.sso_enabled() => {
_check_is_some(&data.client_id, "client_id cannot be blank")?; _check_is_some(&data.client_id, "client_id cannot be blank")?;
_check_is_some(&data.code, "code cannot be blank")?; _check_is_some(&data.code, "code cannot be blank")?;
_check_is_some(&data.code_verifier, "code verifier cannot be blank")?;
_check_is_some(&data.device_identifier, "device_identifier cannot be blank")?; _check_is_some(&data.device_identifier, "device_identifier cannot be blank")?;
_check_is_some(&data.device_name, "device_name cannot be blank")?; _check_is_some(&data.device_name, "device_name cannot be blank")?;
@ -175,17 +176,23 @@ async fn _sso_login(
// Ratelimit the login // Ratelimit the login
crate::ratelimit::check_limit_login(&ip.ip)?; crate::ratelimit::check_limit_login(&ip.ip)?;
let code = match data.code.as_ref() { let (state, code_verifier) = match (data.code.as_ref(), data.code_verifier.as_ref()) {
None => err!( (None, _) => err!(
"Got no code in OIDC data", "Got no code in OIDC data",
ErrorEvent { ErrorEvent {
event: EventType::UserFailedLogIn event: EventType::UserFailedLogIn
} }
), ),
Some(code) => code, (_, None) => err!(
"Got no code verifier in OIDC data",
ErrorEvent {
event: EventType::UserFailedLogIn
}
),
(Some(code), Some(code_verifier)) => (code, code_verifier.clone()),
}; };
let user_infos = sso::exchange_code(code, conn).await?; let (sso_auth, user_infos) = sso::exchange_code(state, code_verifier, conn).await?;
let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await { let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await {
None => match SsoUser::find_by_mail(&user_infos.email, conn).await { None => match SsoUser::find_by_mail(&user_infos.email, conn).await {
None => None, None => None,
@ -248,7 +255,7 @@ async fn _sso_login(
_ => (), _ => (),
} }
let mut user = User::new(user_infos.email, user_infos.user_name); let mut user = User::new(user_infos.email.clone(), user_infos.user_name.clone());
user.verified_at = Some(now); user.verified_at = Some(now);
user.save(conn).await?; user.save(conn).await?;
@ -272,8 +279,8 @@ async fn _sso_login(
if user.private_key.is_none() { if user.private_key.is_none() {
// User was invited a stub was created // User was invited a stub was created
user.verified_at = Some(now); user.verified_at = Some(now);
if let Some(user_name) = user_infos.user_name { if let Some(ref user_name) = user_infos.user_name {
user.name = user_name; user.name = user_name.clone();
} }
user.save(conn).await?; user.save(conn).await?;
@ -290,28 +297,11 @@ async fn _sso_login(
} }
}; };
// We passed 2FA get full user information
let auth_user = sso::redeem(&user_infos.state, conn).await?;
if sso_user.is_none() {
let user_sso = SsoUser {
user_uuid: user.uuid.clone(),
identifier: user_infos.identifier,
};
user_sso.save(conn).await?;
}
// Set the user_uuid here to be passed back used for event logging. // Set the user_uuid here to be passed back used for event logging.
*user_id = Some(user.uuid.clone()); *user_id = Some(user.uuid.clone());
let auth_tokens = sso::create_auth_tokens( // We passed 2FA get auth tokens
&device, let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, conn).await?;
&user,
data.client_id,
auth_user.refresh_token,
auth_user.access_token,
auth_user.expires_in,
)?;
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, &now, conn, ip).await authenticated_response(&user, &mut device, auth_tokens, twofactor_token, &now, conn, ip).await
} }
@ -1002,9 +992,12 @@ struct ConnectData {
two_factor_remember: Option<i32>, two_factor_remember: Option<i32>,
#[field(name = uncased("authrequest"))] #[field(name = uncased("authrequest"))]
auth_request: Option<AuthRequestId>, auth_request: Option<AuthRequestId>,
// Needed for authorization code // Needed for authorization code
#[field(name = uncased("code"))] #[field(name = uncased("code"))]
code: Option<String>, code: Option<OIDCState>,
#[field(name = uncased("code_verifier"))]
code_verifier: Option<OIDCCodeVerifier>,
} }
fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult { fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult {
if value.is_none() { if value.is_none() {
@ -1026,14 +1019,13 @@ fn prevalidate() -> JsonResult {
} }
#[get("/connect/oidc-signin?<code>&<state>", rank = 1)] #[get("/connect/oidc-signin?<code>&<state>", rank = 1)]
async fn oidcsignin(code: OIDCCode, state: String, conn: DbConn) -> ApiResult<Redirect> { async fn oidcsignin(code: OIDCCode, state: String, mut conn: DbConn) -> ApiResult<Redirect> {
oidcsignin_redirect( _oidcsignin_redirect(
state, state,
|decoded_state| sso::OIDCCodeWrapper::Ok { OIDCCodeWrapper::Ok {
state: decoded_state,
code, code,
}, },
&conn, &mut conn,
) )
.await .await
} }
@ -1045,42 +1037,44 @@ async fn oidcsignin_error(
state: String, state: String,
error: String, error: String,
error_description: Option<String>, error_description: Option<String>,
conn: DbConn, mut conn: DbConn,
) -> ApiResult<Redirect> { ) -> ApiResult<Redirect> {
oidcsignin_redirect( _oidcsignin_redirect(
state, state,
|decoded_state| sso::OIDCCodeWrapper::Error { OIDCCodeWrapper::Error {
state: decoded_state,
error, error,
error_description, error_description,
}, },
&conn, &mut conn,
) )
.await .await
} }
// The state was encoded using Base64 to ensure no issue with providers. // The state was encoded using Base64 to ensure no issue with providers.
// iss and scope parameters are needed for redirection to work on IOS. // iss and scope parameters are needed for redirection to work on IOS.
async fn oidcsignin_redirect( // We pass the state as the code to get it back later on.
async fn _oidcsignin_redirect(
base64_state: String, base64_state: String,
wrapper: impl FnOnce(OIDCState) -> sso::OIDCCodeWrapper, code_response: OIDCCodeWrapper,
conn: &DbConn, conn: &mut DbConn,
) -> ApiResult<Redirect> { ) -> ApiResult<Redirect> {
let state = sso::decode_state(base64_state)?; let state = sso::decode_state(base64_state)?;
let code = sso::encode_code_claims(wrapper(state.clone()));
let nonce = match SsoNonce::find(&state, conn).await { let mut sso_auth = match SsoAuth::find(&state, conn).await {
Some(n) => n, None => err!(format!("Cannot retrieve sso_auth for {state}")),
None => err!(format!("Failed to retrieve redirect_uri with {state}")), Some(sso_auth) => sso_auth,
}; };
sso_auth.code_response = Some(code_response);
sso_auth.updated_at = Utc::now().naive_utc();
sso_auth.save(conn).await?;
let mut url = match url::Url::parse(&nonce.redirect_uri) { let mut url = match url::Url::parse(&sso_auth.redirect_uri) {
Ok(url) => url, Ok(url) => url,
Err(err) => err!(format!("Failed to parse redirect uri ({}): {err}", nonce.redirect_uri)), Err(err) => err!(format!("Failed to parse redirect uri ({}): {err}", sso_auth.redirect_uri)),
}; };
url.query_pairs_mut() url.query_pairs_mut()
.append_pair("code", &code) .append_pair("code", &state)
.append_pair("state", &state) .append_pair("state", &state)
.append_pair("scope", &AuthMethod::Sso.scope()) .append_pair("scope", &AuthMethod::Sso.scope())
.append_pair("iss", &CONFIG.domain()); .append_pair("iss", &CONFIG.domain());
@ -1103,10 +1097,8 @@ struct AuthorizeData {
#[allow(unused)] #[allow(unused)]
scope: Option<String>, scope: Option<String>,
state: OIDCState, state: OIDCState,
#[allow(unused)] code_challenge: OIDCCodeChallenge,
code_challenge: Option<String>, code_challenge_method: String,
#[allow(unused)]
code_challenge_method: Option<String>,
#[allow(unused)] #[allow(unused)]
response_mode: Option<String>, response_mode: Option<String>,
#[allow(unused)] #[allow(unused)]
@ -1123,10 +1115,16 @@ async fn authorize(data: AuthorizeData, conn: DbConn) -> ApiResult<Redirect> {
client_id, client_id,
redirect_uri, redirect_uri,
state, state,
code_challenge,
code_challenge_method,
.. ..
} = data; } = data;
let auth_url = sso::authorize_url(state, &client_id, &redirect_uri, conn).await?; if code_challenge_method != "S256" {
err!("Unsupported code challenge method");
}
let auth_url = sso::authorize_url(state, code_challenge, &client_id, &redirect_uri, conn).await?;
Ok(Redirect::temporary(String::from(auth_url))) Ok(Redirect::temporary(String::from(auth_url)))
} }

4
src/config.rs

@ -461,9 +461,9 @@ make_config! {
/// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt. /// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
/// Defaults to once every minute. Set blank to disable this job. /// Defaults to once every minute. Set blank to disable this job.
duo_context_purge_schedule: String, false, def, "30 * * * * *".to_string(); duo_context_purge_schedule: String, false, def, "30 * * * * *".to_string();
/// Purge incomplete SSO nonce. |> Cron schedule of the job that cleans leftover nonce in db due to incomplete SSO login. /// Purge incomplete SSO auth. |> Cron schedule of the job that cleans leftover auth in db due to incomplete SSO login.
/// Defaults to daily. Set blank to disable this job. /// Defaults to daily. Set blank to disable this job.
purge_incomplete_sso_nonce: String, false, def, "0 20 0 * * *".to_string(); purge_incomplete_sso_auth: String, false, def, "0 20 0 * * *".to_string();
}, },
/// General settings /// General settings

4
src/db/models/mod.rs

@ -11,7 +11,7 @@ mod group;
mod org_policy; mod org_policy;
mod organization; mod organization;
mod send; mod send;
mod sso_nonce; mod sso_auth;
mod two_factor; mod two_factor;
mod two_factor_duo_context; mod two_factor_duo_context;
mod two_factor_incomplete; mod two_factor_incomplete;
@ -36,7 +36,7 @@ pub use self::send::{
id::{SendFileId, SendId}, id::{SendFileId, SendId},
Send, SendType, Send, SendType,
}; };
pub use self::sso_nonce::SsoNonce; pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeWrapper, SsoAuth};
pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_duo_context::TwoFactorDuoContext;
pub use self::two_factor_incomplete::TwoFactorIncomplete; pub use self::two_factor_incomplete::TwoFactorIncomplete;

230
src/db/models/sso_auth.rs

@ -0,0 +1,230 @@
use chrono::{NaiveDateTime, Utc};
use std::time::Duration;
use crate::api::EmptyResult;
use crate::db::{DbConn, DbPool};
use crate::error::MapResult;
use crate::sso::{OIDCCode, OIDCCodeChallenge, OIDCIdentifier, OIDCState, SSO_AUTH_EXPIRATION};
use diesel::deserialize::FromSql;
use diesel::expression::AsExpression;
use diesel::serialize::{Output, ToSql};
use diesel::sql_types::{Json, Text};
#[derive(AsExpression, Clone, Debug, Serialize, Deserialize, FromSqlRow)]
#[diesel(sql_type = Text)]
#[diesel(sql_type = Json)]
pub enum OIDCCodeWrapper {
Ok {
code: OIDCCode,
},
Error {
error: String,
error_description: Option<String>,
},
}
#[cfg(sqlite)]
impl ToSql<Text, diesel::sqlite::Sqlite> for OIDCCodeWrapper {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::sqlite::Sqlite>) -> diesel::serialize::Result {
serde_json::to_string(self)
.map(|str| {
out.set_value(str);
diesel::serialize::IsNull::No
})
.map_err(Into::into)
}
}
#[cfg(sqlite)]
impl<B: diesel::backend::Backend> FromSql<Text, B> for OIDCCodeWrapper
where
String: FromSql<Text, B>,
{
fn from_sql(bytes: B::RawValue<'_>) -> diesel::deserialize::Result<Self> {
<String as FromSql<Text, B>>::from_sql(bytes).and_then(|str| serde_json::from_str(&str).map_err(Into::into))
}
}
#[cfg(postgresql)]
impl FromSql<Json, diesel::pg::Pg> for OIDCCodeWrapper {
fn from_sql(value: diesel::pg::PgValue<'_>) -> diesel::deserialize::Result<Self> {
serde_json::from_slice(value.as_bytes()).map_err(|_| "Invalid Json".into())
}
}
#[cfg(postgresql)]
impl ToSql<Json, diesel::pg::Pg> for OIDCCodeWrapper {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::pg::Pg>) -> diesel::serialize::Result {
serde_json::to_writer(out, self).map(|_| diesel::serialize::IsNull::No).map_err(Into::into)
}
}
#[cfg(mysql)]
impl FromSql<Json, diesel::mysql::Mysql> for OIDCCodeWrapper {
fn from_sql(value: diesel::mysql::MysqlValue<'_>) -> diesel::deserialize::Result<Self> {
serde_json::from_slice(value.as_bytes()).map_err(|_| "Invalid Json".into())
}
}
#[cfg(mysql)]
impl ToSql<Json, diesel::mysql::Mysql> for OIDCCodeWrapper {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::mysql::Mysql>) -> diesel::serialize::Result {
serde_json::to_writer(out, self).map(|_| diesel::serialize::IsNull::No).map_err(Into::into)
}
}
#[derive(AsExpression, Clone, Debug, Serialize, Deserialize, FromSqlRow)]
#[diesel(sql_type = Text)]
#[diesel(sql_type = Json)]
pub struct OIDCAuthenticatedUser {
pub refresh_token: Option<String>,
pub access_token: String,
pub expires_in: Option<Duration>,
pub identifier: OIDCIdentifier,
pub email: String,
pub email_verified: Option<bool>,
pub user_name: Option<String>,
}
#[cfg(sqlite)]
impl ToSql<Text, diesel::sqlite::Sqlite> for OIDCAuthenticatedUser {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::sqlite::Sqlite>) -> diesel::serialize::Result {
serde_json::to_string(self)
.map(|str| {
out.set_value(str);
diesel::serialize::IsNull::No
})
.map_err(Into::into)
}
}
#[cfg(sqlite)]
impl<B: diesel::backend::Backend> FromSql<Text, B> for OIDCAuthenticatedUser
where
String: FromSql<Text, B>,
{
fn from_sql(bytes: B::RawValue<'_>) -> diesel::deserialize::Result<Self> {
<String as FromSql<Text, B>>::from_sql(bytes).and_then(|str| serde_json::from_str(&str).map_err(Into::into))
}
}
#[cfg(postgresql)]
impl FromSql<Json, diesel::pg::Pg> for OIDCAuthenticatedUser {
fn from_sql(value: diesel::pg::PgValue<'_>) -> diesel::deserialize::Result<Self> {
serde_json::from_slice(value.as_bytes()).map_err(|_| "Invalid Json".into())
}
}
#[cfg(postgresql)]
impl ToSql<Json, diesel::pg::Pg> for OIDCAuthenticatedUser {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::pg::Pg>) -> diesel::serialize::Result {
serde_json::to_writer(out, self).map(|_| diesel::serialize::IsNull::No).map_err(Into::into)
}
}
#[cfg(mysql)]
impl FromSql<Json, diesel::mysql::Mysql> for OIDCAuthenticatedUser {
fn from_sql(value: diesel::mysql::MysqlValue<'_>) -> diesel::deserialize::Result<Self> {
serde_json::from_slice(value.as_bytes()).map_err(|_| "Invalid Json".into())
}
}
#[cfg(mysql)]
impl ToSql<Json, diesel::mysql::Mysql> for OIDCAuthenticatedUser {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::mysql::Mysql>) -> diesel::serialize::Result {
serde_json::to_writer(out, self).map(|_| diesel::serialize::IsNull::No).map_err(Into::into)
}
}
db_object! {
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = sso_auth)]
#[diesel(primary_key(state))]
pub struct SsoAuth {
pub state: OIDCState,
pub client_challenge: OIDCCodeChallenge,
pub nonce: String,
pub redirect_uri: String,
pub code_response: Option<OIDCCodeWrapper>,
pub auth_response: Option<OIDCAuthenticatedUser>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
}
/// Local methods
impl SsoAuth {
pub fn new(state: OIDCState, client_challenge: OIDCCodeChallenge, nonce: String, redirect_uri: String) -> Self {
let now = Utc::now().naive_utc();
SsoAuth {
state,
client_challenge,
nonce,
redirect_uri,
created_at: now,
updated_at: now,
code_response: None,
auth_response: None,
}
}
}
/// Database methods
impl SsoAuth {
pub async fn save(&self, conn: &mut DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
diesel::replace_into(sso_auth::table)
.values(SsoAuthDb::to_db(self))
.execute(conn)
.map_res("Error saving SSO auth")
}
postgresql {
let value = SsoAuthDb::to_db(self);
diesel::insert_into(sso_auth::table)
.values(&value)
.on_conflict(sso_auth::state)
.do_update()
.set(&value)
.execute(conn)
.map_res("Error saving SSO auth")
}
}
}
pub async fn find(state: &OIDCState, conn: &DbConn) -> Option<Self> {
let oldest = Utc::now().naive_utc() - *SSO_AUTH_EXPIRATION;
db_run! { conn: {
sso_auth::table
.filter(sso_auth::state.eq(state))
.filter(sso_auth::created_at.ge(oldest))
.first::<SsoAuthDb>(conn)
.ok()
.from_db()
}}
}
pub async fn delete(self, conn: &mut DbConn) -> EmptyResult {
db_run! {conn: {
diesel::delete(sso_auth::table.filter(sso_auth::state.eq(self.state)))
.execute(conn)
.map_res("Error deleting sso_auth")
}}
}
pub async fn delete_expired(pool: DbPool) -> EmptyResult {
debug!("Purging expired sso_auth");
if let Ok(conn) = pool.get().await {
let oldest = Utc::now().naive_utc() - *SSO_AUTH_EXPIRATION;
db_run! { conn: {
diesel::delete(sso_auth::table.filter(sso_auth::created_at.lt(oldest)))
.execute(conn)
.map_res("Error deleting expired SSO nonce")
}}
} else {
err!("Failed to get DB connection while purging expired sso_auth")
}
}
}

89
src/db/models/sso_nonce.rs

@ -1,89 +0,0 @@
use chrono::{NaiveDateTime, Utc};
use crate::api::EmptyResult;
use crate::db::{DbConn, DbPool};
use crate::error::MapResult;
use crate::sso::{OIDCState, NONCE_EXPIRATION};
db_object! {
#[derive(Identifiable, Queryable, Insertable)]
#[diesel(table_name = sso_nonce)]
#[diesel(primary_key(state))]
pub struct SsoNonce {
pub state: OIDCState,
pub nonce: String,
pub verifier: Option<String>,
pub redirect_uri: String,
pub created_at: NaiveDateTime,
}
}
/// Local methods
impl SsoNonce {
pub fn new(state: OIDCState, nonce: String, verifier: Option<String>, redirect_uri: String) -> Self {
let now = Utc::now().naive_utc();
SsoNonce {
state,
nonce,
verifier,
redirect_uri,
created_at: now,
}
}
}
/// Database methods
impl SsoNonce {
pub async fn save(&self, conn: &mut DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
diesel::replace_into(sso_nonce::table)
.values(SsoNonceDb::to_db(self))
.execute(conn)
.map_res("Error saving SSO nonce")
}
postgresql {
let value = SsoNonceDb::to_db(self);
diesel::insert_into(sso_nonce::table)
.values(&value)
.execute(conn)
.map_res("Error saving SSO nonce")
}
}
}
pub async fn delete(state: &OIDCState, conn: &mut DbConn) -> EmptyResult {
db_run! { conn: {
diesel::delete(sso_nonce::table.filter(sso_nonce::state.eq(state)))
.execute(conn)
.map_res("Error deleting SSO nonce")
}}
}
pub async fn find(state: &OIDCState, conn: &DbConn) -> Option<Self> {
let oldest = Utc::now().naive_utc() - *NONCE_EXPIRATION;
db_run! { conn: {
sso_nonce::table
.filter(sso_nonce::state.eq(state))
.filter(sso_nonce::created_at.ge(oldest))
.first::<SsoNonceDb>(conn)
.ok()
.from_db()
}}
}
pub async fn delete_expired(pool: DbPool) -> EmptyResult {
debug!("Purging expired sso_nonce");
if let Ok(conn) = pool.get().await {
let oldest = Utc::now().naive_utc() - *NONCE_EXPIRATION;
db_run! { conn: {
diesel::delete(sso_nonce::table.filter(sso_nonce::created_at.lt(oldest)))
.execute(conn)
.map_res("Error deleting expired SSO nonce")
}}
} else {
err!("Failed to get DB connection while purging expired sso_nonce")
}
}
}

7
src/db/schemas/mysql/schema.rs

@ -256,12 +256,15 @@ table! {
} }
table! { table! {
sso_nonce (state) { sso_auth (state) {
state -> Text, state -> Text,
client_challenge -> Text,
nonce -> Text, nonce -> Text,
verifier -> Nullable<Text>,
redirect_uri -> Text, redirect_uri -> Text,
code_response -> Nullable<Json>,
auth_response -> Nullable<Json>,
created_at -> Timestamp, created_at -> Timestamp,
updated_at -> Timestamp,
} }
} }

7
src/db/schemas/postgresql/schema.rs

@ -256,12 +256,15 @@ table! {
} }
table! { table! {
sso_nonce (state) { sso_auth (state) {
state -> Text, state -> Text,
client_challenge -> Text,
nonce -> Text, nonce -> Text,
verifier -> Nullable<Text>,
redirect_uri -> Text, redirect_uri -> Text,
code_response -> Nullable<Json>,
auth_response -> Nullable<Json>,
created_at -> Timestamp, created_at -> Timestamp,
updated_at -> Timestamp,
} }
} }

7
src/db/schemas/sqlite/schema.rs

@ -256,12 +256,15 @@ table! {
} }
table! { table! {
sso_nonce (state) { sso_auth (state) {
state -> Text, state -> Text,
client_challenge -> Text,
nonce -> Text, nonce -> Text,
verifier -> Nullable<Text>,
redirect_uri -> Text, redirect_uri -> Text,
code_response -> Nullable<Text>,
auth_response -> Nullable<Text>,
created_at -> Timestamp, created_at -> Timestamp,
updated_at -> Timestamp,
} }
} }

8
src/main.rs

@ -714,10 +714,10 @@ fn schedule_jobs(pool: db::DbPool) {
})); }));
} }
// Purge sso nonce from incomplete flow (default to daily at 00h20). // Purge sso auth from incomplete flow (default to daily at 00h20).
if !CONFIG.purge_incomplete_sso_nonce().is_empty() { if !CONFIG.purge_incomplete_sso_auth().is_empty() {
sched.add(Job::new(CONFIG.purge_incomplete_sso_nonce().parse().unwrap(), || { sched.add(Job::new(CONFIG.purge_incomplete_sso_auth().parse().unwrap(), || {
runtime.spawn(db::models::SsoNonce::delete_expired(pool.clone())); runtime.spawn(db::models::SsoAuth::delete_expired(pool.clone()));
})); }));
} }

236
src/sso.rs

@ -1,10 +1,9 @@
use chrono::Utc; use chrono::Utc;
use derive_more::{AsRef, Deref, Display, From}; use derive_more::{AsRef, Deref, Display, From, Into};
use regex::Regex; use regex::Regex;
use std::time::Duration; use std::time::Duration;
use url::Url; use url::Url;
use mini_moka::sync::Cache;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use crate::{ use crate::{
@ -12,7 +11,7 @@ use crate::{
auth, auth,
auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY},
db::{ db::{
models::{Device, SsoNonce, User}, models::{Device, OIDCAuthenticatedUser, OIDCCodeWrapper, SsoAuth, SsoUser, User},
DbConn, DbConn,
}, },
sso_client::Client, sso_client::Client,
@ -21,12 +20,9 @@ use crate::{
pub static FAKE_IDENTIFIER: &str = "Vaultwarden"; pub static FAKE_IDENTIFIER: &str = "Vaultwarden";
static AC_CACHE: Lazy<Cache<OIDCState, AuthenticatedUser>> =
Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build());
static SSO_JWT_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|sso", CONFIG.domain_origin())); static SSO_JWT_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|sso", CONFIG.domain_origin()));
pub static NONCE_EXPIRATION: Lazy<chrono::Duration> = Lazy::new(|| chrono::TimeDelta::try_minutes(10).unwrap()); pub static SSO_AUTH_EXPIRATION: Lazy<chrono::Duration> = Lazy::new(|| chrono::TimeDelta::try_minutes(10).unwrap());
#[derive( #[derive(
Clone, Clone,
@ -48,6 +44,47 @@ pub static NONCE_EXPIRATION: Lazy<chrono::Duration> = Lazy::new(|| chrono::TimeD
#[from(forward)] #[from(forward)]
pub struct OIDCCode(String); pub struct OIDCCode(String);
#[derive(
Clone,
Debug,
Default,
DieselNewType,
FromForm,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
AsRef,
Deref,
Display,
From,
Into,
)]
#[deref(forward)]
#[into(owned)]
pub struct OIDCCodeChallenge(String);
#[derive(
Clone,
Debug,
Default,
DieselNewType,
FromForm,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
AsRef,
Deref,
Display,
Into,
)]
#[deref(forward)]
#[into(owned)]
pub struct OIDCCodeVerifier(String);
#[derive( #[derive(
Clone, Clone,
Debug, Debug,
@ -92,40 +129,6 @@ pub fn encode_ssotoken_claims() -> String {
auth::encode_jwt(&claims) auth::encode_jwt(&claims)
} }
#[derive(Debug, Serialize, Deserialize)]
pub enum OIDCCodeWrapper {
Ok {
state: OIDCState,
code: OIDCCode,
},
Error {
state: OIDCState,
error: String,
error_description: Option<String>,
},
}
#[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)] #[derive(Clone, Debug, Serialize, Deserialize)]
struct BasicTokenClaims { struct BasicTokenClaims {
iat: Option<i64>, iat: Option<i64>,
@ -163,10 +166,10 @@ pub fn decode_state(base64_state: String) -> ApiResult<OIDCState> {
Ok(state) 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 // redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/ApiClient.cs
pub async fn authorize_url( pub async fn authorize_url(
state: OIDCState, state: OIDCState,
client_challenge: OIDCCodeChallenge,
client_id: &str, client_id: &str,
raw_redirect_uri: &str, raw_redirect_uri: &str,
mut conn: DbConn, mut conn: DbConn,
@ -184,8 +187,8 @@ pub async fn authorize_url(
_ => err!(format!("Unsupported client {client_id}")), _ => err!(format!("Unsupported client {client_id}")),
}; };
let (auth_url, nonce) = Client::authorize_url(state, redirect_uri).await?; let (auth_url, sso_auth) = Client::authorize_url(state, client_challenge, redirect_uri).await?;
nonce.save(&mut conn).await?; sso_auth.save(&mut conn).await?;
Ok(auth_url) Ok(auth_url)
} }
@ -215,78 +218,45 @@ impl OIDCIdentifier {
} }
} }
#[derive(Clone, Debug)]
pub struct AuthenticatedUser {
pub refresh_token: Option<String>,
pub access_token: String,
pub expires_in: Option<Duration>,
pub identifier: OIDCIdentifier,
pub email: String,
pub email_verified: Option<bool>,
pub user_name: Option<String>,
}
#[derive(Clone, Debug)]
pub struct UserInformation {
pub state: OIDCState,
pub identifier: OIDCIdentifier,
pub email: String,
pub email_verified: Option<bool>,
pub user_name: Option<String>,
}
async fn decode_code_claims(code: &str, conn: &mut DbConn) -> ApiResult<(OIDCCode, OIDCState)> {
match auth::decode_jwt::<OIDCCodeClaims>(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 // During the 2FA flow we will
// - retrieve the user information and then only discover he needs 2FA. // - 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. // - 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. // The `SsoAuth` 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(
pub async fn exchange_code(wrapped_code: &str, conn: &mut DbConn) -> ApiResult<UserInformation> { state: &OIDCState,
client_verifier: OIDCCodeVerifier,
conn: &mut DbConn,
) -> ApiResult<(SsoAuth, OIDCAuthenticatedUser)> {
use openidconnect::OAuth2TokenResponse; use openidconnect::OAuth2TokenResponse;
let (code, state) = decode_code_claims(wrapped_code, conn).await?; let mut sso_auth = match SsoAuth::find(state, conn).await {
None => err!(format!("Invalid state cannot retrieve sso auth")),
Some(sso_auth) => sso_auth,
};
if let Some(authenticated_user) = AC_CACHE.get(&state) { if let Some(authenticated_user) = sso_auth.auth_response.clone() {
return Ok(UserInformation { return Ok((sso_auth, authenticated_user));
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 { let code = match sso_auth.code_response.clone() {
None => err!(format!("Invalid state cannot retrieve nonce")), Some(OIDCCodeWrapper::Ok {
Some(nonce) => nonce, code,
}) => code.clone(),
Some(OIDCCodeWrapper::Error {
error,
error_description,
}) => {
sso_auth.delete(conn).await?;
err!(format!("SSO authorization failed: {error}, {}", error_description.as_ref().unwrap_or(&String::new())))
}
None => {
sso_auth.delete(conn).await?;
err!("Missing authorization provider return");
}
}; };
let client = Client::cached().await?; let client = Client::cached().await?;
let (token_response, id_claims) = client.exchange_code(code, nonce).await?; let (token_response, id_claims) = client.exchange_code(code, client_verifier, &sso_auth).await?;
let user_info = client.user_info(token_response.access_token().to_owned()).await?; let user_info = client.user_info(token_response.access_token().to_owned()).await?;
@ -306,7 +276,7 @@ pub async fn exchange_code(wrapped_code: &str, conn: &mut DbConn) -> ApiResult<U
let identifier = OIDCIdentifier::new(id_claims.issuer(), id_claims.subject()); let identifier = OIDCIdentifier::new(id_claims.issuer(), id_claims.subject());
let authenticated_user = AuthenticatedUser { let authenticated_user = OIDCAuthenticatedUser {
refresh_token: refresh_token.cloned(), refresh_token: refresh_token.cloned(),
access_token: token_response.access_token().secret().clone(), access_token: token_response.access_token().secret().clone(),
expires_in: token_response.expires_in(), expires_in: token_response.expires_in(),
@ -317,29 +287,49 @@ pub async fn exchange_code(wrapped_code: &str, conn: &mut DbConn) -> ApiResult<U
}; };
debug!("Authenticated user {authenticated_user:?}"); debug!("Authenticated user {authenticated_user:?}");
sso_auth.auth_response = Some(authenticated_user.clone());
sso_auth.updated_at = Utc::now().naive_utc();
sso_auth.save(conn).await?;
AC_CACHE.insert(state.clone(), authenticated_user); Ok((sso_auth, authenticated_user))
Ok(UserInformation {
state,
identifier,
email,
email_verified,
user_name,
})
} }
// User has passed 2FA flow we can delete `nonce` and clear the cache. // User has passed 2FA flow we can delete auth info from database
pub async fn redeem(state: &OIDCState, conn: &mut DbConn) -> ApiResult<AuthenticatedUser> { pub async fn redeem(
if let Err(err) = SsoNonce::delete(state, conn).await { device: &Device,
error!("Failed to delete database sso_nonce using {state}: {err}") user: &User,
client_id: Option<String>,
sso_user: Option<SsoUser>,
sso_auth: SsoAuth,
auth_user: OIDCAuthenticatedUser,
conn: &mut DbConn,
) -> ApiResult<AuthTokens> {
sso_auth.delete(conn).await?;
if sso_user.is_none() {
let user_sso = SsoUser {
user_uuid: user.uuid.clone(),
identifier: auth_user.identifier.clone(),
};
user_sso.save(conn).await?;
} }
if let Some(au) = AC_CACHE.get(state) { if !CONFIG.sso_auth_only_not_session() {
AC_CACHE.invalidate(state); let now = Utc::now();
Ok(au)
let (ap_nbf, ap_exp) =
match (decode_token_claims("access_token", &auth_user.access_token), auth_user.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, auth_user.refresh_token, access_claims, auth_user.access_token)
} else { } else {
err!("Failed to retrieve user info from sso cache") Ok(AuthTokens::new(device, user, AuthMethod::Sso, client_id))
} }
} }

39
src/sso_client.rs

@ -11,8 +11,8 @@ use openidconnect::*;
use crate::{ use crate::{
api::{ApiResult, EmptyResult}, api::{ApiResult, EmptyResult},
db::models::SsoNonce, db::models::SsoAuth,
sso::{OIDCCode, OIDCState}, sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState},
CONFIG, CONFIG,
}; };
@ -111,7 +111,11 @@ impl Client {
} }
// The `state` is encoded using base64 to ensure no issue with providers (It contains the Organization identifier). // The `state` is encoded using base64 to ensure no issue with providers (It contains the Organization identifier).
pub async fn authorize_url(state: OIDCState, redirect_uri: String) -> ApiResult<(Url, SsoNonce)> { pub async fn authorize_url(
state: OIDCState,
client_challenge: OIDCCodeChallenge,
redirect_uri: String,
) -> ApiResult<(Url, SsoAuth)> {
let scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new); let scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new);
let base64_state = data_encoding::BASE64.encode(state.to_string().as_bytes()); let base64_state = data_encoding::BASE64.encode(state.to_string().as_bytes());
@ -126,22 +130,21 @@ impl Client {
.add_scopes(scopes) .add_scopes(scopes)
.add_extra_params(CONFIG.sso_authorize_extra_params_vec()); .add_extra_params(CONFIG.sso_authorize_extra_params_vec());
let verifier = if CONFIG.sso_pkce() { if CONFIG.sso_pkce() {
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); auth_req = auth_req
auth_req = auth_req.set_pkce_challenge(pkce_challenge); .add_extra_param::<&str, String>("code_challenge", client_challenge.clone().into())
Some(pkce_verifier.into_secret()) .add_extra_param("code_challenge_method", "S256");
} else { }
None
};
let (auth_url, _, nonce) = auth_req.url(); let (auth_url, _, nonce) = auth_req.url();
Ok((auth_url, SsoNonce::new(state, nonce.secret().clone(), verifier, redirect_uri))) Ok((auth_url, SsoAuth::new(state, client_challenge, nonce.secret().clone(), redirect_uri)))
} }
pub async fn exchange_code( pub async fn exchange_code(
&self, &self,
code: OIDCCode, code: OIDCCode,
nonce: SsoNonce, client_verifier: OIDCCodeVerifier,
sso_auth: &SsoAuth,
) -> ApiResult<( ) -> ApiResult<(
StandardTokenResponse< StandardTokenResponse<
IdTokenFields< IdTokenFields<
@ -159,17 +162,21 @@ impl Client {
let mut exchange = self.core_client.exchange_code(oidc_code); let mut exchange = self.core_client.exchange_code(oidc_code);
let verifier = PkceCodeVerifier::new(client_verifier.into());
if CONFIG.sso_pkce() { if CONFIG.sso_pkce() {
match nonce.verifier { exchange = exchange.set_pkce_verifier(verifier);
None => err!(format!("Missing verifier in the DB nonce table")), } else {
Some(secret) => exchange = exchange.set_pkce_verifier(PkceCodeVerifier::new(secret.clone())), let challenge = PkceCodeChallenge::from_code_verifier_sha256(&verifier);
if challenge.as_str() != String::from(sso_auth.client_challenge.clone()) {
err!(format!("PKCE client challenge failed"))
// Might need to notify admin ? how ?
} }
} }
match exchange.request_async(&self.http_client).await { match exchange.request_async(&self.http_client).await {
Err(err) => err!(format!("Failed to contact token endpoint: {:?}", err)), Err(err) => err!(format!("Failed to contact token endpoint: {:?}", err)),
Ok(token_response) => { Ok(token_response) => {
let oidc_nonce = Nonce::new(nonce.nonce); let oidc_nonce = Nonce::new(sso_auth.nonce.clone());
let id_token = match token_response.extra_fields().id_token() { let id_token = match token_response.extra_fields().id_token() {
None => err!("Token response did not contain an id_token"), None => err!("Token response did not contain an id_token"),

Loading…
Cancel
Save