diff --git a/Cargo.lock b/Cargo.lock index 8c7c88e619..9b08fcbd57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/api_models/src/events/user.rs b/crates/api_models/src/events/user.rs index e5d217cd8e..4f5651e0a3 100644 --- a/crates/api_models/src/events/user.rs +++ b/crates/api_models/src/events/user.rs @@ -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")] diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs index 0006afea0d..b2ed491b67 100644 --- a/crates/api_models/src/user.rs +++ b/crates/api_models/src/user.rs @@ -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, } +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct GetSsoAuthUrlRequest { + pub id: String, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct SsoSignInRequest { + pub state: Secret, + pub code: Secret, +} + #[derive(Debug, serde::Deserialize, serde::Serialize)] pub struct AuthIdQueryParam { pub auth_id: Option, diff --git a/crates/diesel_models/src/query/user_authentication_method.rs b/crates/diesel_models/src/query/user_authentication_method.rs index 08ea6556b8..14a28269ce 100644 --- a/crates/diesel_models/src/query/user_authentication_method.rs +++ b/crates/diesel_models/src/query/user_authentication_method.rs @@ -12,6 +12,13 @@ impl UserAuthenticationMethodNew { } impl UserAuthenticationMethod { + pub async fn get_user_authentication_method_by_id( + conn: &PgPooledConn, + id: &str, + ) -> StorageResult { + generics::generic_find_by_id::<::Table, _, _>(conn, id.to_owned()).await + } + pub async fn list_user_authentication_methods_for_auth_id( conn: &PgPooledConn, auth_id: &str, diff --git a/crates/router/Cargo.toml b/crates/router/Cargo.toml index e48a6b4419..dd14c8d896 100644 --- a/crates/router/Cargo.toml +++ b/crates/router/Cargo.toml @@ -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" diff --git a/crates/router/src/analytics.rs b/crates/router/src/analytics.rs index 80b4e18807..ae3b03f7b8 100644 --- a/crates/router/src/analytics.rs +++ b/crates/router/src/analytics.rs @@ -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) diff --git a/crates/router/src/consts/user.rs b/crates/router/src/consts/user.rs index 331c256e8a..261f8a1efc 100644 --- a/crates/router/src/consts/user.rs +++ b/crates/router/src/consts/user.rs @@ -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 diff --git a/crates/router/src/core/errors/user.rs b/crates/router/src/core/errors/user.rs index 28b80e16cc..1cd37679ad 100644 --- a/crates/router/src/core/errors/user.rs +++ b/crates/router/src/core/errors/user.rs @@ -86,6 +86,8 @@ pub enum UserErrors { InvalidUserAuthMethodOperation, #[error("Auth config parsing error")] AuthConfigParsingError, + #[error("Invalid SSO request")] + SSOFailed, } impl common_utils::errors::ErrorSwitch for UserErrors { @@ -219,6 +221,9 @@ impl common_utils::errors::ErrorSwitch { 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", } } } diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs index 0fd583143c..c4260a9294 100644 --- a/crates/router/src/core/user.rs +++ b/crates/router/src/core/user.rs @@ -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::>()?, )) } + +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, +) -> UserResponse { + 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) +} diff --git a/crates/router/src/db/kafka_store.rs b/crates/router/src/db/kafka_store.rs index e60bcc79bd..e5686d616d 100644 --- a/crates/router/src/db/kafka_store.rs +++ b/crates/router/src/db/kafka_store.rs @@ -2971,6 +2971,15 @@ impl UserAuthenticationMethodInterface for KafkaStore { .await } + async fn get_user_authentication_method_by_id( + &self, + id: &str, + ) -> CustomResult { + self.diesel_store + .get_user_authentication_method_by_id(id) + .await + } + async fn list_user_authentication_methods_for_auth_id( &self, auth_id: &str, diff --git a/crates/router/src/db/user_authentication_method.rs b/crates/router/src/db/user_authentication_method.rs index 5b9aa5da8c..bcc2313d5d 100644 --- a/crates/router/src/db/user_authentication_method.rs +++ b/crates/router/src/db/user_authentication_method.rs @@ -16,6 +16,11 @@ pub trait UserAuthenticationMethodInterface { user_authentication_method: storage::UserAuthenticationMethodNew, ) -> CustomResult; + async fn get_user_authentication_method_by_id( + &self, + id: &str, + ) -> CustomResult; + 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 { + 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 { + 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, diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 0124f6ae6a..80157c476b 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -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")] diff --git a/crates/router/src/routes/dummy_connector.rs b/crates/router/src/routes/dummy_connector.rs index 79338fc620..a0c85f90ef 100644 --- a/crates/router/src/routes/dummy_connector.rs +++ b/crates/router/src/routes/dummy_connector.rs @@ -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, ) diff --git a/crates/router/src/routes/health.rs b/crates/router/src/routes/health.rs index 3e2f42bcc2..f15a0db192 100644 --- a/crates/router/src/routes/health.rs +++ b/crates/router/src/routes/health.rs @@ -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, )) diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index e5c4f1c8e9..115bcf6b9a 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -230,7 +230,9 @@ impl From for ApiIdentifier { | Flow::TwoFactorAuthStatus | Flow::CreateUserAuthenticationMethod | Flow::UpdateUserAuthenticationMethod - | Flow::ListUserAuthenticationMethods => Self::User, + | Flow::ListUserAuthenticationMethods + | Flow::GetSsoAuthUrl + | Flow::SignInWithSso => Self::User, Flow::ListRoles | Flow::GetRole diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs index 816e7f7e2f..dae78d31bf 100644 --- a/crates/router/src/routes/user.rs +++ b/crates/router/src/routes/user.rs @@ -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, + req: HttpRequest, + query: web::Query, +) -> 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, + req: HttpRequest, + json_payload: web::Json, +) -> HttpResponse { + let flow = Flow::SignInWithSso; + let payload = json_payload.into_inner(); + Box::pin(api::server_wrap( + flow, + state.clone(), + &req, + payload, + |state, user: Option, 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, 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, )) diff --git a/crates/router/src/services.rs b/crates/router/src/services.rs index 7aed9fdb40..8792f0c8d8 100644 --- a/crates/router/src/services.rs +++ b/crates/router/src/services.rs @@ -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; diff --git a/crates/router/src/services/api/client.rs b/crates/router/src/services/api/client.rs index add93e9911..7f8a01736e 100644 --- a/crates/router/src/services/api/client.rs +++ b/crates/router/src/services/api/client.rs @@ -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>, diff --git a/crates/router/src/services/authentication.rs b/crates/router/src/services/authentication.rs index 43d49b92ef..adca724e45 100644 --- a/crates/router/src/services/authentication.rs +++ b/crates/router/src/services/authentication.rs @@ -261,6 +261,20 @@ where } } +#[async_trait] +impl AuthenticateAndFetch, A> for NoAuth +where + A: SessionStateInfo + Sync, +{ + async fn authenticate_and_fetch( + &self, + _request_headers: &HeaderMap, + _state: &A, + ) -> RouterResult<(Option, AuthenticationType)> { + Ok((None, AuthenticationType::NoAuth)) + } +} + #[async_trait] impl AuthenticateAndFetch for ApiKeyAuth where @@ -372,6 +386,40 @@ where } } +#[cfg(feature = "olap")] +#[async_trait] +impl AuthenticateAndFetch, A> for SinglePurposeJWTAuth +where + A: SessionStateInfo + Sync, +{ + async fn authenticate_and_fetch( + &self, + request_headers: &HeaderMap, + state: &A, + ) -> RouterResult<(Option, AuthenticationType)> { + let payload = parse_jwt_payload::(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); diff --git a/crates/router/src/services/openidconnect.rs b/crates/router/src/services/openidconnect.rs new file mode 100644 index 0000000000..4f5056f327 --- /dev/null +++ b/crates/router/src/services/openidconnect.rs @@ -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, + base_url: Secret, + client_id: Secret, +) -> UserResult { + 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, + base_url: Secret, + client_id: Secret, + authorization_code: Secret, + client_secret: Secret, +) -> UserResult { + 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, + state: &SessionState, +) -> UserResult { + 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, + client_secret: Option>, + redirect_url: String, +) -> UserResult { + 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, +) -> UserResult { + 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::>(&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 { + 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> { + state + .store + .get_redis_conn() + .change_context(UserErrors::InternalServerError) + .attach_printable("Failed to get redis connection") +} diff --git a/crates/router/src/utils/user.rs b/crates/router/src/utils/user.rs index 25c851549d..6b957281f7 100644 --- a/crates/router/src/utils/user.rs +++ b/crates/router/src/utils/user.rs @@ -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> { +) -> UserResult> { 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> { +) -> UserResult> { 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, + token: Secret, ) -> UserResult { 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 { +pub fn get_token_from_signin_response(resp: &user_api::SignInResponse) -> Secret { match resp { user_api::SignInResponse::DashboardEntry(data) => data.token.clone(), user_api::SignInResponse::MerchantSelect(data) => data.token.clone(), @@ -213,3 +215,74 @@ impl ForeignFrom for common_enums::UserAuthType { } } } + +pub async fn decrypt_oidc_private_config( + state: &SessionState, + encrypted_config: Option, +) -> UserResult { + 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::( + 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, + 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, +) -> UserResult { + let connection = get_redis_connection(state)?; + let key = get_oidc_key(&oidc_state.expose()); + connection + .get_key::>(&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) +} diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index 46bb93f06e..023cd2a794 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -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