feat(users): implemented openidconnect (#5124)

Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
Rachit Naithani
2024-06-26 19:15:55 +05:30
committed by GitHub
parent ed021c1d99
commit ce7d0d427d
22 changed files with 877 additions and 41 deletions

268
Cargo.lock generated
View File

@ -1430,6 +1430,12 @@ dependencies = [
"rustc-demangle",
]
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base32"
version = "0.4.0"
@ -2307,6 +2313,18 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
"rand_core",
"subtle",
"zeroize",
]
[[package]]
name = "crypto-common"
version = "0.1.6"
@ -2328,6 +2346,34 @@ dependencies = [
"thiserror",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a677b8922c94e01bdbb12126b0bc852f00447528dee1782229af9c720c3f348"
dependencies = [
"cfg-if 1.0.0",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"platforms",
"rustc_version 0.4.0",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.57",
]
[[package]]
name = "darling"
version = "0.14.4"
@ -2677,6 +2723,44 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125"
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "either"
version = "1.10.0"
@ -2686,6 +2770,27 @@ dependencies = [
"serde",
]
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"ff",
"generic-array",
"group",
"hkdf",
"pem-rfc7468",
"pkcs8",
"rand_core",
"sec1",
"subtle",
"zeroize",
]
[[package]]
name = "encoding_rs"
version = "0.8.33"
@ -2915,6 +3020,22 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "ff"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449"
dependencies = [
"rand_core",
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "finl_unicode"
version = "1.2.0"
@ -3197,6 +3318,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@ -3287,6 +3409,17 @@ dependencies = [
"tempfile",
]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core",
"subtle",
]
[[package]]
name = "h2"
version = "0.3.25"
@ -4550,6 +4683,26 @@ dependencies = [
"libc",
]
[[package]]
name = "oauth2"
version = "4.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f"
dependencies = [
"base64 0.13.1",
"chrono",
"getrandom",
"http 0.2.12",
"rand",
"reqwest",
"serde",
"serde_json",
"serde_path_to_error",
"sha2",
"thiserror",
"url",
]
[[package]]
name = "object"
version = "0.32.2"
@ -4596,6 +4749,38 @@ dependencies = [
"utoipa",
]
[[package]]
name = "openidconnect"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f47e80a9cfae4462dd29c41e987edd228971d6565553fbc14b8a11e666d91590"
dependencies = [
"base64 0.13.1",
"chrono",
"dyn-clone",
"ed25519-dalek",
"hmac",
"http 0.2.12",
"itertools 0.10.5",
"log",
"oauth2",
"p256",
"p384",
"rand",
"rsa",
"serde",
"serde-value",
"serde_derive",
"serde_json",
"serde_path_to_error",
"serde_plain",
"serde_with",
"sha2",
"subtle",
"thiserror",
"url",
]
[[package]]
name = "opensearch"
version = "2.2.0"
@ -4743,6 +4928,15 @@ dependencies = [
"tokio-stream",
]
[[package]]
name = "ordered-float"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
dependencies = [
"num-traits",
]
[[package]]
name = "ordered-multimap"
version = "0.6.0"
@ -4765,6 +4959,30 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
]
[[package]]
name = "p384"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70786f51bcc69f6a4c0360e063a4cac5419ef7c5cd5b3c99ad70f3be5ba79209"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
]
[[package]]
name = "parking_lot"
version = "0.9.0"
@ -5040,6 +5258,12 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
[[package]]
name = "platforms"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db23d408679286588f4d4644f965003d056e3dd5abcaaa938116871d7ce2fee7"
[[package]]
name = "plotters"
version = "0.3.5"
@ -5123,6 +5347,15 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]]
name = "proc-macro-crate"
version = "1.3.1"
@ -5584,6 +5817,16 @@ dependencies = [
"winreg",
]
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac",
"subtle",
]
[[package]]
name = "ring"
version = "0.16.20"
@ -5728,6 +5971,7 @@ dependencies = [
"num_cpus",
"once_cell",
"openapi",
"openidconnect",
"openssl",
"pm_auth",
"qrcode",
@ -6116,6 +6360,20 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array",
"pkcs8",
"subtle",
"zeroize",
]
[[package]]
name = "security-framework"
version = "2.9.2"
@ -6172,6 +6430,16 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-value"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
dependencies = [
"ordered-float",
"serde",
]
[[package]]
name = "serde-wasm-bindgen"
version = "0.5.0"

View File

@ -12,14 +12,14 @@ use crate::user::{
},
AcceptInviteFromEmailRequest, AuthorizeResponse, BeginTotpResponse, ChangePasswordRequest,
ConnectAccountRequest, CreateInternalUserRequest, CreateUserAuthenticationMethodRequest,
DashboardEntryResponse, ForgotPasswordRequest, GetUserAuthenticationMethodsRequest,
GetUserDetailsResponse, GetUserRoleDetailsRequest, GetUserRoleDetailsResponse,
InviteUserRequest, ListUsersResponse, ReInviteUserRequest, RecoveryCodes, ResetPasswordRequest,
RotatePasswordRequest, SendVerifyEmailRequest, SignInResponse, SignUpRequest,
SignUpWithMerchantIdRequest, SwitchMerchantIdRequest, TokenOrPayloadResponse, TokenResponse,
TwoFactorAuthStatusResponse, UpdateUserAccountDetailsRequest,
UpdateUserAuthenticationMethodRequest, UserFromEmailRequest, UserMerchantCreate,
VerifyEmailRequest, VerifyRecoveryCodeRequest, VerifyTotpRequest,
DashboardEntryResponse, ForgotPasswordRequest, GetSsoAuthUrlRequest,
GetUserAuthenticationMethodsRequest, GetUserDetailsResponse, GetUserRoleDetailsRequest,
GetUserRoleDetailsResponse, InviteUserRequest, ListUsersResponse, ReInviteUserRequest,
RecoveryCodes, ResetPasswordRequest, RotatePasswordRequest, SendVerifyEmailRequest,
SignInResponse, SignUpRequest, SignUpWithMerchantIdRequest, SsoSignInRequest,
SwitchMerchantIdRequest, TokenOrPayloadResponse, TokenResponse, TwoFactorAuthStatusResponse,
UpdateUserAccountDetailsRequest, UpdateUserAuthenticationMethodRequest, UserFromEmailRequest,
UserMerchantCreate, VerifyEmailRequest, VerifyRecoveryCodeRequest, VerifyTotpRequest,
};
impl ApiEventMetric for DashboardEntryResponse {
@ -81,7 +81,9 @@ common_utils::impl_misc_api_event_type!(
RecoveryCodes,
GetUserAuthenticationMethodsRequest,
CreateUserAuthenticationMethodRequest,
UpdateUserAuthenticationMethodRequest
UpdateUserAuthenticationMethodRequest,
GetSsoAuthUrlRequest,
SsoSignInRequest
);
#[cfg(feature = "dummy_connector")]

View File

@ -306,8 +306,9 @@ pub struct OpenIdConnectPublicConfig {
pub name: OpenIdProvider,
}
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, strum::Display)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum OpenIdProvider {
Okta,
}
@ -356,6 +357,17 @@ pub struct AuthMethodDetails {
pub name: Option<OpenIdProvider>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct GetSsoAuthUrlRequest {
pub id: String,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct SsoSignInRequest {
pub state: Secret<String>,
pub code: Secret<String>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct AuthIdQueryParam {
pub auth_id: Option<String>,

View File

@ -12,6 +12,13 @@ impl UserAuthenticationMethodNew {
}
impl UserAuthenticationMethod {
pub async fn get_user_authentication_method_by_id(
conn: &PgPooledConn,
id: &str,
) -> StorageResult<Self> {
generics::generic_find_by_id::<<Self as HasTable>::Table, _, _>(conn, id.to_owned()).await
}
pub async fn list_user_authentication_methods_for_auth_id(
conn: &PgPooledConn,
auth_id: &str,

View File

@ -66,6 +66,7 @@ mime = "0.3.17"
nanoid = "0.4.0"
num_cpus = "1.16.0"
once_cell = "1.19.0"
openidconnect = "3.5.0" # TODO: remove reqwest
openssl = "0.10.64"
qrcode = "0.14.0"
rand = "0.8.5"

View File

@ -130,7 +130,7 @@ pub mod routes {
state,
&req,
domain.into_inner(),
|_, _, domain: analytics::AnalyticsDomain, _| async {
|_, _: (), domain: analytics::AnalyticsDomain, _| async {
analytics::core::get_domain_info(domain)
.await
.map(ApplicationResponse::Json)

View File

@ -19,3 +19,6 @@ pub const REDIS_TOTP_PREFIX: &str = "TOTP_";
pub const REDIS_RECOVERY_CODE_PREFIX: &str = "RC_";
pub const REDIS_TOTP_SECRET_PREFIX: &str = "TOTP_SEC_";
pub const REDIS_TOTP_SECRET_TTL_IN_SECS: i64 = 15 * 60; // 15 minutes
pub const REDIS_SSO_PREFIX: &str = "SSO_";
pub const REDIS_SSO_TTL: i64 = 5 * 60; // 5 minutes

View File

@ -86,6 +86,8 @@ pub enum UserErrors {
InvalidUserAuthMethodOperation,
#[error("Auth config parsing error")]
AuthConfigParsingError,
#[error("Invalid SSO request")]
SSOFailed,
}
impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for UserErrors {
@ -219,6 +221,9 @@ impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorRespon
Self::AuthConfigParsingError => {
AER::BadRequest(ApiError::new(sub_code, 45, self.get_error_message(), None))
}
Self::SSOFailed => {
AER::BadRequest(ApiError::new(sub_code, 46, self.get_error_message(), None))
}
}
}
}
@ -265,6 +270,7 @@ impl UserErrors {
Self::UserAuthMethodAlreadyExists => "User auth method already exists",
Self::InvalidUserAuthMethodOperation => "Invalid user auth method operation",
Self::AuthConfigParsingError => "Auth config parsing error",
Self::SSOFailed => "Invalid SSO request",
}
}
}

View File

@ -1,6 +1,9 @@
use std::collections::HashMap;
use api_models::user::{self as user_api, InviteMultipleUserResponse};
use api_models::{
payments::RedirectionResponse,
user::{self as user_api, InviteMultipleUserResponse},
};
use common_utils::ext_traits::ValueExt;
#[cfg(feature = "email")]
use diesel_models::user_role::UserRoleUpdate;
@ -13,7 +16,7 @@ use diesel_models::{
use error_stack::{report, ResultExt};
#[cfg(feature = "email")]
use external_services::email::EmailData;
use masking::{ExposeInterface, PeekInterface};
use masking::{ExposeInterface, PeekInterface, Secret};
#[cfg(feature = "email")]
use router_env::env;
use router_env::logger;
@ -26,7 +29,7 @@ use crate::services::email::types as email_types;
use crate::{
consts,
routes::{app::ReqState, SessionState},
services::{authentication as auth, authorization::roles, ApplicationResponse},
services::{authentication as auth, authorization::roles, openidconnect, ApplicationResponse},
types::{domain, transformers::ForeignInto},
utils::{self, user::two_factor_auth as tfa_utils},
};
@ -2198,3 +2201,114 @@ pub async fn list_user_authentication_methods(
.collect::<UserResult<_>>()?,
))
}
pub async fn get_sso_auth_url(
state: SessionState,
request: user_api::GetSsoAuthUrlRequest,
) -> UserResponse<()> {
let user_authentication_method = state
.store
.get_user_authentication_method_by_id(request.id.as_str())
.await
.to_not_found_response(UserErrors::InvalidUserAuthMethodOperation)?;
let open_id_private_config =
utils::user::decrypt_oidc_private_config(&state, user_authentication_method.private_config)
.await?;
let open_id_public_config: user_api::OpenIdConnectPublicConfig = user_authentication_method
.public_config
.ok_or(UserErrors::InternalServerError)
.attach_printable("Public config not present")?
.parse_value("OpenIdConnectPublicConfig")
.change_context(UserErrors::InternalServerError)
.attach_printable("unable to parse OpenIdConnectPublicConfig")?;
let oidc_state = Secret::new(nanoid::nanoid!());
utils::user::set_sso_id_in_redis(&state, oidc_state.clone(), request.id).await?;
let redirect_url =
utils::user::get_oidc_sso_redirect_url(&state, &open_id_public_config.name.to_string());
openidconnect::get_authorization_url(
state,
redirect_url,
oidc_state,
open_id_private_config.base_url.into(),
open_id_private_config.client_id,
)
.await
.map(|url| {
ApplicationResponse::JsonForRedirection(RedirectionResponse {
headers: Vec::with_capacity(0),
return_url: String::new(),
http_method: String::new(),
params: Vec::with_capacity(0),
return_url_with_query_params: url.to_string(),
})
})
}
pub async fn sso_sign(
state: SessionState,
request: user_api::SsoSignInRequest,
user_from_single_purpose_token: Option<auth::UserFromSinglePurposeToken>,
) -> UserResponse<user_api::TokenResponse> {
let authentication_method_id =
utils::user::get_sso_id_from_redis(&state, request.state.clone()).await?;
let user_authentication_method = state
.store
.get_user_authentication_method_by_id(&authentication_method_id)
.await
.change_context(UserErrors::InternalServerError)?;
let open_id_private_config =
utils::user::decrypt_oidc_private_config(&state, user_authentication_method.private_config)
.await?;
let open_id_public_config: user_api::OpenIdConnectPublicConfig = user_authentication_method
.public_config
.ok_or(UserErrors::InternalServerError)
.attach_printable("Public config not present")?
.parse_value("OpenIdConnectPublicConfig")
.change_context(UserErrors::InternalServerError)
.attach_printable("unable to parse OpenIdConnectPublicConfig")?;
let redirect_url =
utils::user::get_oidc_sso_redirect_url(&state, &open_id_public_config.name.to_string());
let email = openidconnect::get_user_email_from_oidc_provider(
&state,
redirect_url,
request.state,
open_id_private_config.base_url.into(),
open_id_private_config.client_id,
request.code,
open_id_private_config.client_secret,
)
.await?;
// TODO: Use config to handle not found error
let user_from_db = state
.global_store
.find_user_by_email(&email.into_inner())
.await
.map(Into::into)
.to_not_found_response(UserErrors::UserNotFound)?;
let next_flow = if let Some(user_from_single_purpose_token) = user_from_single_purpose_token {
let current_flow =
domain::CurrentFlow::new(user_from_single_purpose_token, domain::SPTFlow::SSO.into())?;
current_flow.next(user_from_db, &state).await?
} else {
domain::NextFlow::from_origin(domain::Origin::SignInWithSSO, user_from_db, &state).await?
};
let token = next_flow.get_token(&state).await?;
let response = user_api::TokenResponse {
token: token.clone(),
token_type: next_flow.get_flow().into(),
};
auth::cookies::set_cookie_response(response, token)
}

View File

@ -2971,6 +2971,15 @@ impl UserAuthenticationMethodInterface for KafkaStore {
.await
}
async fn get_user_authentication_method_by_id(
&self,
id: &str,
) -> CustomResult<storage::UserAuthenticationMethod, errors::StorageError> {
self.diesel_store
.get_user_authentication_method_by_id(id)
.await
}
async fn list_user_authentication_methods_for_auth_id(
&self,
auth_id: &str,

View File

@ -16,6 +16,11 @@ pub trait UserAuthenticationMethodInterface {
user_authentication_method: storage::UserAuthenticationMethodNew,
) -> CustomResult<storage::UserAuthenticationMethod, errors::StorageError>;
async fn get_user_authentication_method_by_id(
&self,
id: &str,
) -> CustomResult<storage::UserAuthenticationMethod, errors::StorageError>;
async fn list_user_authentication_methods_for_auth_id(
&self,
auth_id: &str,
@ -47,6 +52,17 @@ impl UserAuthenticationMethodInterface for Store {
.map_err(|error| report!(errors::StorageError::from(error)))
}
#[instrument(skip_all)]
async fn get_user_authentication_method_by_id(
&self,
id: &str,
) -> CustomResult<storage::UserAuthenticationMethod, errors::StorageError> {
let conn = connection::pg_connection_write(self).await?;
storage::UserAuthenticationMethod::get_user_authentication_method_by_id(&conn, id)
.await
.map_err(|error| report!(errors::StorageError::from(error)))
}
#[instrument(skip_all)]
async fn list_user_authentication_methods_for_auth_id(
&self,
@ -120,6 +136,28 @@ impl UserAuthenticationMethodInterface for MockDb {
Ok(user_authentication_method)
}
#[instrument(skip_all)]
async fn get_user_authentication_method_by_id(
&self,
id: &str,
) -> CustomResult<storage::UserAuthenticationMethod, errors::StorageError> {
let user_authentication_methods = self.user_authentication_methods.lock().await;
let user_authentication_method = user_authentication_methods
.iter()
.find(|&auth_method_inner| auth_method_inner.id == id);
if let Some(user_authentication_method) = user_authentication_method {
Ok(user_authentication_method.to_owned())
} else {
return Err(errors::StorageError::ValueNotFound(format!(
"No user authentication method found for id = {}",
id
))
.into());
}
}
async fn list_user_authentication_methods_for_auth_id(
&self,
auth_id: &str,

View File

@ -1351,6 +1351,8 @@ impl User {
route = route
.service(web::resource("").route(web::get().to(get_user_details)))
.service(web::resource("/v2/signin").route(web::post().to(user_signin)))
// signin/signup with sso using openidconnect
.service(web::resource("/oidc").route(web::post().to(sso_sign)))
.service(web::resource("/signout").route(web::post().to(signout)))
.service(web::resource("/rotate_password").route(web::post().to(rotate_password)))
.service(web::resource("/change_password").route(web::post().to(change_password)))
@ -1414,7 +1416,8 @@ impl User {
)
.service(
web::resource("/list").route(web::get().to(list_user_authentication_methods)),
),
)
.service(web::resource("/url").route(web::get().to(get_sso_auth_url))),
);
#[cfg(feature = "email")]

View File

@ -27,7 +27,7 @@ pub async fn dummy_connector_authorize_payment(
state,
&req,
payload,
|state, _, req, _| core::payment_authorize(state, req),
|state, _: (), req, _| core::payment_authorize(state, req),
&auth::NoAuth,
api_locking::LockAction::NotApplicable,
)
@ -51,7 +51,7 @@ pub async fn dummy_connector_complete_payment(
state,
&req,
payload,
|state, _, req, _| core::payment_complete(state, req),
|state, _: (), req, _| core::payment_complete(state, req),
&auth::NoAuth,
api_locking::LockAction::NotApplicable,
)
@ -70,7 +70,7 @@ pub async fn dummy_connector_payment(
state,
&req,
payload,
|state, _, req, _| core::payment(state, req),
|state, _: (), req, _| core::payment(state, req),
&auth::NoAuth,
api_locking::LockAction::NotApplicable,
)
@ -90,7 +90,7 @@ pub async fn dummy_connector_payment_data(
state,
&req,
payload,
|state, _, req, _| core::payment_data(state, req),
|state, _: (), req, _| core::payment_data(state, req),
&auth::NoAuth,
api_locking::LockAction::NotApplicable,
)
@ -111,7 +111,7 @@ pub async fn dummy_connector_refund(
state,
&req,
payload,
|state, _, req, _| core::refund_payment(state, req),
|state, _: (), req, _| core::refund_payment(state, req),
&auth::NoAuth,
api_locking::LockAction::NotApplicable,
)
@ -131,7 +131,7 @@ pub async fn dummy_connector_refund_data(
state,
&req,
payload,
|state, _, req, _| core::refund_data(state, req),
|state, _: (), req, _| core::refund_data(state, req),
&auth::NoAuth,
api_locking::LockAction::NotApplicable,
)

View File

@ -34,7 +34,7 @@ pub async fn deep_health_check(
state,
&request,
(),
|state, _, _, _| deep_health_check_func(state),
|state, _: (), _, _| deep_health_check_func(state),
&auth::NoAuth,
api_locking::LockAction::NotApplicable,
))

View File

@ -230,7 +230,9 @@ impl From<Flow> for ApiIdentifier {
| Flow::TwoFactorAuthStatus
| Flow::CreateUserAuthenticationMethod
| Flow::UpdateUserAuthenticationMethod
| Flow::ListUserAuthenticationMethods => Self::User,
| Flow::ListUserAuthenticationMethods
| Flow::GetSsoAuthUrl
| Flow::SignInWithSso => Self::User,
Flow::ListRoles
| Flow::GetRole

View File

@ -72,7 +72,7 @@ pub async fn user_signup(
state,
&http_req,
req_payload.clone(),
|state, _, req_body, _| async move {
|state, _: (), req_body, _| async move {
if let Some(true) = is_token_only {
user_core::signup_token_only_flow(state, req_body).await
} else {
@ -99,7 +99,7 @@ pub async fn user_signin(
state,
&http_req,
req_payload.clone(),
|state, _, req_body, _| async move {
|state, _: (), req_body, _| async move {
if let Some(true) = is_token_only {
user_core::signin_token_only_flow(state, req_body).await
} else {
@ -127,7 +127,7 @@ pub async fn user_connect_account(
state,
&http_req,
req_payload.clone(),
|state, _, req_body, _| user_core::connect_account(state, req_body, auth_id.clone()),
|state, _: (), req_body, _| user_core::connect_account(state, req_body, auth_id.clone()),
&auth::NoAuth,
api_locking::LockAction::NotApplicable,
))
@ -397,7 +397,7 @@ pub async fn forgot_password(
state.clone(),
&req,
payload.into_inner(),
|state, _, payload, _| user_core::forgot_password(state, payload, auth_id.clone()),
|state, _: (), payload, _| user_core::forgot_password(state, payload, auth_id.clone()),
&auth::NoAuth,
api_locking::LockAction::NotApplicable,
))
@ -432,7 +432,7 @@ pub async fn reset_password(
state.clone(),
&req,
payload.into_inner(),
|state, _, payload, _| user_core::reset_password(state, payload),
|state, _: (), payload, _| user_core::reset_password(state, payload),
&auth::NoAuth,
api_locking::LockAction::NotApplicable,
))
@ -522,7 +522,7 @@ pub async fn accept_invite_from_email(
state.clone(),
&req,
payload.into_inner(),
|state, _, request_payload, _| {
|state, _: (), request_payload, _| {
user_core::accept_invite_from_email(state, request_payload)
},
&auth::NoAuth,
@ -560,7 +560,7 @@ pub async fn verify_email(
state,
&http_req,
json_payload.into_inner(),
|state, _, req_payload, _| user_core::verify_email(state, req_payload),
|state, _: (), req_payload, _| user_core::verify_email(state, req_payload),
&auth::NoAuth,
api_locking::LockAction::NotApplicable,
))
@ -582,7 +582,9 @@ pub async fn verify_email_request(
state.clone(),
&http_req,
json_payload.into_inner(),
|state, _, req_body, _| user_core::send_verification_mail(state, req_body, auth_id.clone()),
|state, _: (), req_body, _| {
user_core::send_verification_mail(state, req_body, auth_id.clone())
},
&auth::NoAuth,
api_locking::LockAction::NotApplicable,
))
@ -774,6 +776,50 @@ pub async fn check_two_factor_auth_status(
.await
}
pub async fn get_sso_auth_url(
state: web::Data<AppState>,
req: HttpRequest,
query: web::Query<user_api::GetSsoAuthUrlRequest>,
) -> HttpResponse {
let flow = Flow::GetSsoAuthUrl;
let payload = query.into_inner();
Box::pin(api::server_wrap(
flow,
state.clone(),
&req,
payload,
|state, _: (), req, _| user_core::get_sso_auth_url(state, req),
&auth::NoAuth,
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn sso_sign(
state: web::Data<AppState>,
req: HttpRequest,
json_payload: web::Json<user_api::SsoSignInRequest>,
) -> HttpResponse {
let flow = Flow::SignInWithSso;
let payload = json_payload.into_inner();
Box::pin(api::server_wrap(
flow,
state.clone(),
&req,
payload,
|state, user: Option<auth::UserFromSinglePurposeToken>, payload, _| {
user_core::sso_sign(state, payload, user)
},
auth::auth_type(
&auth::NoAuth,
&auth::SinglePurposeJWTAuth(TokenPurpose::SSO),
req.headers(),
),
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn create_user_authentication_method(
state: web::Data<AppState>,
req: HttpRequest,
@ -824,7 +870,7 @@ pub async fn list_user_authentication_methods(
state.clone(),
&req,
query.into_inner(),
|state, _, req, _| user_core::list_user_authentication_methods(state, req),
|state, _: (), req, _| user_core::list_user_authentication_methods(state, req),
&auth::NoAuth,
api_locking::LockAction::NotApplicable,
))

View File

@ -14,6 +14,9 @@ pub mod pm_auth;
#[cfg(feature = "recon")]
pub mod recon;
#[cfg(feature = "olap")]
pub mod openidconnect;
use std::sync::Arc;
use error_stack::ResultExt;

View File

@ -80,7 +80,7 @@ fn get_base_client(
// We may need to use outbound proxy to connect to external world.
// Precedence will be the environment variables, followed by the config.
pub(super) fn create_client(
pub fn create_client(
proxy_config: &Proxy,
should_bypass_proxy: bool,
client_certificate: Option<masking::Secret<String>>,

View File

@ -261,6 +261,20 @@ where
}
}
#[async_trait]
impl<A, T> AuthenticateAndFetch<Option<T>, A> for NoAuth
where
A: SessionStateInfo + Sync,
{
async fn authenticate_and_fetch(
&self,
_request_headers: &HeaderMap,
_state: &A,
) -> RouterResult<(Option<T>, AuthenticationType)> {
Ok((None, AuthenticationType::NoAuth))
}
}
#[async_trait]
impl<A> AuthenticateAndFetch<AuthenticationData, A> for ApiKeyAuth
where
@ -372,6 +386,40 @@ where
}
}
#[cfg(feature = "olap")]
#[async_trait]
impl<A> AuthenticateAndFetch<Option<UserFromSinglePurposeToken>, A> for SinglePurposeJWTAuth
where
A: SessionStateInfo + Sync,
{
async fn authenticate_and_fetch(
&self,
request_headers: &HeaderMap,
state: &A,
) -> RouterResult<(Option<UserFromSinglePurposeToken>, AuthenticationType)> {
let payload = parse_jwt_payload::<A, SinglePurposeToken>(request_headers, state).await?;
if payload.check_in_blacklist(state).await? {
return Err(errors::ApiErrorResponse::InvalidJwtToken.into());
}
if self.0 != payload.purpose {
return Err(errors::ApiErrorResponse::InvalidJwtToken.into());
}
Ok((
Some(UserFromSinglePurposeToken {
user_id: payload.user_id.clone(),
origin: payload.origin.clone(),
path: payload.path,
}),
AuthenticationType::SinglePurposeJwt {
user_id: payload.user_id,
purpose: payload.purpose,
},
))
}
}
#[cfg(feature = "olap")]
#[derive(Debug)]
pub struct SinglePurposeOrLoginTokenAuth(pub TokenPurpose);

View File

@ -0,0 +1,197 @@
use error_stack::ResultExt;
use masking::{ExposeInterface, Secret};
use oidc::TokenResponse;
use openidconnect::{self as oidc, core as oidc_core};
use redis_interface::RedisConnectionPool;
use storage_impl::errors::ApiClientError;
use crate::{
consts,
core::errors::{UserErrors, UserResult},
routes::SessionState,
services::api::client,
types::domain::user::UserEmail,
};
pub async fn get_authorization_url(
state: SessionState,
redirect_url: String,
redirect_state: Secret<String>,
base_url: Secret<String>,
client_id: Secret<String>,
) -> UserResult<url::Url> {
let discovery_document = get_discovery_document(base_url, &state).await?;
let (auth_url, csrf_token, nonce) =
get_oidc_core_client(discovery_document, client_id, None, redirect_url)?
.authorize_url(
oidc_core::CoreAuthenticationFlow::AuthorizationCode,
|| oidc::CsrfToken::new(redirect_state.expose()),
oidc::Nonce::new_random,
)
.add_scope(oidc::Scope::new("email".to_string()))
.url();
// Save csrf & nonce as key value respectively
let key = get_oidc_redis_key(csrf_token.secret());
get_redis_connection(&state)?
.set_key_with_expiry(&key, nonce.secret(), consts::user::REDIS_SSO_TTL)
.await
.change_context(UserErrors::InternalServerError)
.attach_printable("Failed to save csrf-nonce in redis")?;
Ok(auth_url)
}
pub async fn get_user_email_from_oidc_provider(
state: &SessionState,
redirect_url: String,
redirect_state: Secret<String>,
base_url: Secret<String>,
client_id: Secret<String>,
authorization_code: Secret<String>,
client_secret: Secret<String>,
) -> UserResult<UserEmail> {
let nonce = get_nonce_from_redis(state, &redirect_state).await?;
let discovery_document = get_discovery_document(base_url, state).await?;
let client = get_oidc_core_client(
discovery_document,
client_id,
Some(client_secret),
redirect_url,
)?;
let nonce_clone = nonce.clone();
client
.authorize_url(
oidc_core::CoreAuthenticationFlow::AuthorizationCode,
|| oidc::CsrfToken::new(redirect_state.expose()),
|| nonce_clone,
)
.add_scope(oidc::Scope::new("email".to_string()));
// Send request to OpenId provider with authorization code
let token_response = client
.exchange_code(oidc::AuthorizationCode::new(authorization_code.expose()))
.request_async(|req| get_oidc_reqwest_client(state, req))
.await
.change_context(UserErrors::InternalServerError)
.attach_printable("Failed to exchange code and fetch oidc token")?;
// Fetch id token from response
let id_token = token_response
.id_token()
.ok_or(UserErrors::InternalServerError)
.attach_printable("Id Token not provided in token response")?;
// Verify id token
let id_token_claims = id_token
.claims(&client.id_token_verifier(), &nonce)
.change_context(UserErrors::InternalServerError)
.attach_printable("Failed to verify id token")?;
// Get email from token
let email_from_token = id_token_claims
.email()
.map(|email| email.to_string())
.ok_or(UserErrors::InternalServerError)
.attach_printable("OpenID Provider Didnt provide email")?;
UserEmail::new(Secret::new(email_from_token))
.change_context(UserErrors::InternalServerError)
.attach_printable("Failed to create email type")
}
// TODO: Cache Discovery Document
async fn get_discovery_document(
base_url: Secret<String>,
state: &SessionState,
) -> UserResult<oidc_core::CoreProviderMetadata> {
let issuer_url =
oidc::IssuerUrl::new(base_url.expose()).change_context(UserErrors::InternalServerError)?;
oidc_core::CoreProviderMetadata::discover_async(issuer_url, |req| {
get_oidc_reqwest_client(state, req)
})
.await
.change_context(UserErrors::InternalServerError)
}
fn get_oidc_core_client(
discovery_document: oidc_core::CoreProviderMetadata,
client_id: Secret<String>,
client_secret: Option<Secret<String>>,
redirect_url: String,
) -> UserResult<oidc_core::CoreClient> {
let client_id = oidc::ClientId::new(client_id.expose());
let client_secret = client_secret.map(|secret| oidc::ClientSecret::new(secret.expose()));
let redirect_url = oidc::RedirectUrl::new(redirect_url)
.change_context(UserErrors::InternalServerError)
.attach_printable("Error creating redirect URL type")?;
Ok(
oidc_core::CoreClient::from_provider_metadata(discovery_document, client_id, client_secret)
.set_redirect_uri(redirect_url),
)
}
async fn get_nonce_from_redis(
state: &SessionState,
redirect_state: &Secret<String>,
) -> UserResult<oidc::Nonce> {
let redis_connection = get_redis_connection(state)?;
let redirect_state = redirect_state.clone().expose();
let key = get_oidc_redis_key(&redirect_state);
redis_connection
.get_key::<Option<String>>(&key)
.await
.change_context(UserErrors::InternalServerError)
.attach_printable("Error Fetching CSRF from redis")?
.map(oidc::Nonce::new)
.ok_or(UserErrors::SSOFailed)
.attach_printable("Cannot find csrf in redis. Csrf invalid or expired")
}
async fn get_oidc_reqwest_client(
state: &SessionState,
request: oidc::HttpRequest,
) -> Result<oidc::HttpResponse, ApiClientError> {
let client = client::create_client(&state.conf.proxy, false, None, None)
.map_err(|e| e.current_context().to_owned())?;
let mut request_builder = client
.request(request.method, request.url)
.body(request.body);
for (name, value) in &request.headers {
request_builder = request_builder.header(name.as_str(), value.as_bytes());
}
let request = request_builder
.build()
.map_err(|_| ApiClientError::ClientConstructionFailed)?;
let response = client
.execute(request)
.await
.map_err(|_| ApiClientError::RequestNotSent("OpenIDConnect".to_string()))?;
Ok(oidc::HttpResponse {
status_code: response.status(),
headers: response.headers().to_owned(),
body: response
.bytes()
.await
.map_err(|_| ApiClientError::ResponseDecodingFailed)?
.to_vec(),
})
}
fn get_oidc_redis_key(csrf: &str) -> String {
format!("{}OIDC_{}", consts::user::REDIS_SSO_PREFIX, csrf)
}
fn get_redis_connection(state: &SessionState) -> UserResult<std::sync::Arc<RedisConnectionPool>> {
state
.store
.get_redis_conn()
.change_context(UserErrors::InternalServerError)
.attach_printable("Failed to get redis connection")
}

View File

@ -1,12 +1,14 @@
use std::{collections::HashMap, sync::Arc};
use api_models::user as user_api;
use common_utils::errors::CustomResult;
use diesel_models::{enums::UserStatus, user_role::UserRole};
use common_utils::{errors::CustomResult, ext_traits::ValueExt};
use diesel_models::{encryption::Encryption, enums::UserStatus, user_role::UserRole};
use error_stack::ResultExt;
use masking::{ExposeInterface, Secret};
use redis_interface::RedisConnectionPool;
use crate::{
consts::user::{REDIS_SSO_PREFIX, REDIS_SSO_TTL},
core::errors::{StorageError, UserErrors, UserResult},
routes::SessionState,
services::{
@ -78,7 +80,7 @@ pub async fn generate_jwt_auth_token(
state: &SessionState,
user: &UserFromStorage,
user_role: &UserRole,
) -> UserResult<masking::Secret<String>> {
) -> UserResult<Secret<String>> {
let token = AuthToken::new_token(
user.get_user_id().to_string(),
user_role.merchant_id.clone(),
@ -87,7 +89,7 @@ pub async fn generate_jwt_auth_token(
user_role.org_id.clone(),
)
.await?;
Ok(masking::Secret::new(token))
Ok(Secret::new(token))
}
pub async fn generate_jwt_auth_token_with_custom_role_attributes(
@ -96,7 +98,7 @@ pub async fn generate_jwt_auth_token_with_custom_role_attributes(
merchant_id: String,
org_id: String,
role_id: String,
) -> UserResult<masking::Secret<String>> {
) -> UserResult<Secret<String>> {
let token = AuthToken::new_token(
user.get_user_id().to_string(),
merchant_id,
@ -105,14 +107,14 @@ pub async fn generate_jwt_auth_token_with_custom_role_attributes(
org_id,
)
.await?;
Ok(masking::Secret::new(token))
Ok(Secret::new(token))
}
pub fn get_dashboard_entry_response(
state: &SessionState,
user: UserFromStorage,
user_role: UserRole,
token: masking::Secret<String>,
token: Secret<String>,
) -> UserResult<user_api::DashboardEntryResponse> {
let verification_days_left = get_verification_days_left(state, &user)?;
@ -189,7 +191,7 @@ pub async fn get_user_from_db_by_email(
.map(UserFromStorage::from)
}
pub fn get_token_from_signin_response(resp: &user_api::SignInResponse) -> masking::Secret<String> {
pub fn get_token_from_signin_response(resp: &user_api::SignInResponse) -> Secret<String> {
match resp {
user_api::SignInResponse::DashboardEntry(data) => data.token.clone(),
user_api::SignInResponse::MerchantSelect(data) => data.token.clone(),
@ -213,3 +215,74 @@ impl ForeignFrom<user_api::AuthConfig> for common_enums::UserAuthType {
}
}
}
pub async fn decrypt_oidc_private_config(
state: &SessionState,
encrypted_config: Option<Encryption>,
) -> UserResult<user_api::OpenIdConnectPrivateConfig> {
let user_auth_key = hex::decode(
state
.conf
.user_auth_methods
.get_inner()
.encryption_key
.clone()
.expose(),
)
.change_context(UserErrors::InternalServerError)
.attach_printable("Failed to decode DEK")?;
let private_config = domain::types::decrypt::<serde_json::Value, masking::WithType>(
encrypted_config,
&user_auth_key,
)
.await
.change_context(UserErrors::InternalServerError)
.attach_printable("Failed to decrypt private config")?
.ok_or(UserErrors::InternalServerError)
.attach_printable("Private config not found")?
.into_inner()
.expose();
private_config
.parse_value("OpenIdConnectPrivateConfig")
.change_context(UserErrors::InternalServerError)
.attach_printable("unable to parse OpenIdConnectPrivateConfig")
}
pub async fn set_sso_id_in_redis(
state: &SessionState,
oidc_state: Secret<String>,
sso_id: String,
) -> UserResult<()> {
let connection = get_redis_connection(state)?;
let key = get_oidc_key(&oidc_state.expose());
connection
.set_key_with_expiry(&key, sso_id, REDIS_SSO_TTL)
.await
.change_context(UserErrors::InternalServerError)
.attach_printable("Failed to set sso id in redis")
}
pub async fn get_sso_id_from_redis(
state: &SessionState,
oidc_state: Secret<String>,
) -> UserResult<String> {
let connection = get_redis_connection(state)?;
let key = get_oidc_key(&oidc_state.expose());
connection
.get_key::<Option<String>>(&key)
.await
.change_context(UserErrors::InternalServerError)
.attach_printable("Failed to get sso id from redis")?
.ok_or(UserErrors::SSOFailed)
.attach_printable("Cannot find oidc state in redis. Oidc state invalid or expired")
}
fn get_oidc_key(oidc_state: &str) -> String {
format!("{}{oidc_state}", REDIS_SSO_PREFIX)
}
pub fn get_oidc_sso_redirect_url(state: &SessionState, provider: &str) -> String {
format!("{}/redirect/oidc/{}", state.conf.user.base_url, provider)
}

View File

@ -432,6 +432,10 @@ pub enum Flow {
UpdateUserAuthenticationMethod,
// List user authentication methods
ListUserAuthenticationMethods,
/// Get sso auth url
GetSsoAuthUrl,
/// Signin with SSO
SignInWithSso,
/// List initial webhook delivery attempts
WebhookEventInitialDeliveryAttemptList,
/// List delivery attempts for a webhook event