From 916d51a4e8160789d62e5d64909e9593783a615e Mon Sep 17 00:00:00 2001 From: Skyler Mansfield <90399509+Skyler84@users.noreply.github.com> Date: Sat, 23 Mar 2024 11:05:12 +0000 Subject: [PATCH] Add support for role mappings on custom SSO providers. (#920) This is done using the `role_mappings` property. Roles to be mapped are gotten from the 'warp_groups` oidc claim: ```sso_providers: - name: custom_sso label: Custom SSO provider: type: custom client_id: client_secret: issuer_url: scopes: ["email", "profile", "openid", "warp_groups"] #warp_groups is scope name to request for my demo case, which adds a "warpgate_groups" claim to the userinfo role_mappings: - ["warpgate:admin", "warpgate:admin"] ``` This maps the `warpgate:admin` group from OIDC to the `warpgate:admin` role. This [video on YouTube](https://youtu.be/XCYSGGCgk9Q) demonstrates the functionality --------- Co-authored-by: Eugene --- Cargo.lock | 37 +++++----- tests/oidc-mock/clients-config.json | 23 ++++++ tests/oidc-mock/docker-compose.yml | 73 +++++++++++++++++++ warpgate-common/src/error.rs | 6 +- warpgate-core/src/auth_state_store.rs | 2 +- warpgate-core/src/config_providers/db.rs | 69 +++++++++++++++++- warpgate-core/src/config_providers/file.rs | 9 +++ warpgate-core/src/config_providers/mod.rs | 7 ++ warpgate-protocol-http/src/api/auth.rs | 2 +- .../src/api/sso_provider_list.rs | 33 +++++++++ warpgate-sso/Cargo.toml | 1 + warpgate-sso/src/config.rs | 25 ++++--- warpgate-sso/src/request.rs | 58 +++++++++++++-- warpgate-sso/src/response.rs | 1 + warpgate-sso/src/sso.rs | 4 +- .../src/admin/UserCredentialModal.svelte | 2 +- 16 files changed, 310 insertions(+), 42 deletions(-) create mode 100644 tests/oidc-mock/clients-config.json create mode 100644 tests/oidc-mock/docker-compose.yml diff --git a/Cargo.lock b/Cargo.lock index fb88935e2..8f378c565 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1613,9 +1613,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.28" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" dependencies = [ "futures-channel", "futures-core", @@ -1628,9 +1628,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.28" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" dependencies = [ "futures-core", "futures-sink", @@ -1638,15 +1638,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.28" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" [[package]] name = "futures-executor" -version = "0.3.28" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" dependencies = [ "futures-core", "futures-task", @@ -1666,9 +1666,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.28" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" [[package]] name = "futures-lite" @@ -1687,9 +1687,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.28" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", @@ -1698,21 +1698,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.28" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" [[package]] name = "futures-task" -version = "0.3.28" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" [[package]] name = "futures-util" -version = "0.3.28" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ "futures-channel", "futures-core", @@ -5644,6 +5644,7 @@ version = "0.9.1" dependencies = [ "bytes", "data-encoding", + "futures", "jsonwebtoken", "once_cell", "openidconnect", diff --git a/tests/oidc-mock/clients-config.json b/tests/oidc-mock/clients-config.json new file mode 100644 index 000000000..41e061c83 --- /dev/null +++ b/tests/oidc-mock/clients-config.json @@ -0,0 +1,23 @@ +[ + { + "ClientId": "client-credentials-mock-client", + "ClientSecrets": ["client-credentials-mock-client-secret"], + "Description": "Client for client credentials flow", + "AllowedGrantTypes": ["client_credentials", "authorization_code"], + "AllowedScopes": ["openid", "profile", "email", "warpgate-scope"], + "RedirectUris": ["https://127.0.0.1:8888/@warpgate/api/sso/return"], + "ClientClaimsPrefix": "", + "Claims": [ + { + "Type": "string_claim", + "Value": "string_claim_value", + "ValueType": "string" + }, + { + "Type": "json_claim", + "Value": "[\"value1\", \"value2\"]", + "ValueType": "json" + } + ] + } + ] diff --git a/tests/oidc-mock/docker-compose.yml b/tests/oidc-mock/docker-compose.yml new file mode 100644 index 000000000..de6af5370 --- /dev/null +++ b/tests/oidc-mock/docker-compose.yml @@ -0,0 +1,73 @@ +version: '3' +services: + oidc-server-mock: + container_name: oidc-server-mock + image: ghcr.io/soluto/oidc-server-mock:latest + ports: + - '4011:80' + environment: + ASPNETCORE_ENVIRONMENT: Development + SERVER_OPTIONS_INLINE: | + { + "AccessTokenJwtType": "JWT", + "Discovery": { + "ShowKeySet": true + }, + "Authentication": { + "CookieSameSiteMode": "Lax", + "CheckSessionCookieSameSiteMode": "Lax" + } + } + LOGIN_OPTIONS_INLINE: | + { + "AllowRememberLogin": false + } + LOGOUT_OPTIONS_INLINE: | + { + "AutomaticRedirectAfterSignOut": true + } + API_SCOPES_INLINE: | + - Name: some-app-scope-1 + - Name: some-app-scope-2 + IDENTITY_RESOURCES_INLINE: | + - Name: warpgate-scope + ClaimTypes: + - warpgate_groups + # API_RESOURCES_INLINE: | + # - Name: wapgate_groups + # Scopes: + # - warpgate + USERS_CONFIGURATION_INLINE: | + [ + { + "SubjectId":"1", + "Username":"User1", + "Password":"pwd", + "Claims": [ + { + "Type": "name", + "Value": "Sam Tailor", + "ValueType": "string" + }, + { + "Type": "email", + "Value": "sam.tailor@gmail.com", + "ValueType": "string" + }, + { + "Type": "warpgate_groups", + "Value": "[\"qa\", \"unknown\"]", + "ValueType": "json" + } + ] + } + ] + CLIENTS_CONFIGURATION_PATH: /tmp/config/clients-config.json + ASPNET_SERVICES_OPTIONS_INLINE: | + { + "ForwardedHeadersOptions": { + "ForwardedHeaders" : "All" + } + } + volumes: + - .:/tmp/config:ro diff --git a/warpgate-common/src/error.rs b/warpgate-common/src/error.rs index 3523eda65..edd5380ca 100644 --- a/warpgate-common/src/error.rs +++ b/warpgate-common/src/error.rs @@ -13,8 +13,10 @@ pub enum WarpgateError { InvalidCredentialType, #[error(transparent)] Other(Box), - #[error("user not found")] - UserNotFound, + #[error("user {0} not found")] + UserNotFound(String), + #[error("role {0} not found")] + RoleNotFound(String), #[error("failed to parse URL: {0}")] UrlParse(#[from] url::ParseError), #[error("deserialization failed: {0}")] diff --git a/warpgate-core/src/auth_state_store.rs b/warpgate-core/src/auth_state_store.rs index 3664a2633..048536e8e 100644 --- a/warpgate-core/src/auth_state_store.rs +++ b/warpgate-core/src/auth_state_store.rs @@ -62,7 +62,7 @@ impl AuthStateStore { .get_credential_policy(username, supported_credential_types) .await?; let Some(policy) = policy else { - return Err(WarpgateError::UserNotFound); + return Err(WarpgateError::UserNotFound(username.into())) }; let state = AuthState::new( diff --git a/warpgate-core/src/config_providers/db.rs b/warpgate-core/src/config_providers/db.rs index f6c7abafa..7b98d1493 100644 --- a/warpgate-core/src/config_providers/db.rs +++ b/warpgate-core/src/config_providers/db.rs @@ -3,7 +3,10 @@ use std::sync::Arc; use async_trait::async_trait; use data_encoding::BASE64; -use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter, QueryOrder}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter, + QueryOrder, Set, +}; use tokio::sync::Mutex; use tracing::*; use warpgate_common::auth::{ @@ -17,7 +20,7 @@ use warpgate_common::{ UserPasswordCredential, UserPublicKeyCredential, UserSsoCredential, UserTotpCredential, WarpgateError, }; -use warpgate_db_entities::{Role, Target, User}; +use warpgate_db_entities::{Role, Target, User, UserRoleAssignment}; use super::ConfigProvider; @@ -294,4 +297,66 @@ impl ConfigProvider for DatabaseConfigProvider { Ok(intersect) } + + async fn apply_sso_role_mappings( + &mut self, + username: &str, + managed_role_names: Option>, + assigned_role_names: Vec, + ) -> Result<(), WarpgateError> { + let db = self.db.lock().await; + + let user = User::Entity::find() + .filter(User::Column::Username.eq(username)) + .one(&*db) + .await + .map_err(WarpgateError::from)? + .ok_or_else(|| WarpgateError::UserNotFound(username.into()))?; + + let managed_role_names = match managed_role_names { + Some(x) => x, + None => Role::Entity::find() + .all(&*db) + .await? + .into_iter() + .map(|x| x.name) + .collect(), + }; + + for role_name in managed_role_names.into_iter() { + let role = Role::Entity::find() + .filter(Role::Column::Name.eq(role_name.clone())) + .one(&*db) + .await + .map_err(WarpgateError::from)? + .ok_or_else(|| WarpgateError::RoleNotFound(role_name.clone()))?; + + let assignment = UserRoleAssignment::Entity::find() + .filter(UserRoleAssignment::Column::UserId.eq(user.id)) + .filter(UserRoleAssignment::Column::RoleId.eq(role.id)) + .one(&*db) + .await + .map_err(WarpgateError::from)?; + + match (assignment, assigned_role_names.contains(&role_name)) { + (None, true) => { + info!("Adding role {role_name} for user {username} (from SSO)"); + let values = UserRoleAssignment::ActiveModel { + user_id: Set(user.id), + role_id: Set(role.id), + ..Default::default() + }; + + values.insert(&*db).await.map_err(WarpgateError::from)?; + } + (Some(assignment), false) => { + info!("Removing role {role_name} for user {username} (from SSO)"); + assignment.delete(&*db).await.map_err(WarpgateError::from)?; + } + _ => (), + } + } + + Ok(()) + } } diff --git a/warpgate-core/src/config_providers/file.rs b/warpgate-core/src/config_providers/file.rs index 20fc71e4e..51b900ed7 100644 --- a/warpgate-core/src/config_providers/file.rs +++ b/warpgate-core/src/config_providers/file.rs @@ -287,4 +287,13 @@ impl ConfigProvider for FileConfigProvider { Ok(intersect) } + + async fn apply_sso_role_mappings( + &mut self, + _username: &str, + _managed_role_names: Option>, + _assigned_role_names: Vec, + ) -> Result<(), WarpgateError> { + Ok(()) + } } diff --git a/warpgate-core/src/config_providers/mod.rs b/warpgate-core/src/config_providers/mod.rs index 3ee54cdc6..6fa75e1c3 100644 --- a/warpgate-core/src/config_providers/mod.rs +++ b/warpgate-core/src/config_providers/mod.rs @@ -31,6 +31,13 @@ pub trait ConfigProvider { client_credential: &AuthCredential, ) -> Result, WarpgateError>; + async fn apply_sso_role_mappings( + &mut self, + username: &str, + managed_role_names: Option>, + active_role_names: Vec, + ) -> Result<(), WarpgateError>; + async fn get_credential_policy( &mut self, username: &str, diff --git a/warpgate-protocol-http/src/api/auth.rs b/warpgate-protocol-http/src/api/auth.rs index 8c91f0176..efc1b432f 100644 --- a/warpgate-protocol-http/src/api/auth.rs +++ b/warpgate-protocol-http/src/api/auth.rs @@ -130,7 +130,7 @@ impl Api { ) .await { - Err(WarpgateError::UserNotFound) => { + Err(WarpgateError::UserNotFound(_)) => { return Ok(LoginResponse::Failure(Json(LoginFailureResponse { state: ApiAuthState::Failed, }))) diff --git a/warpgate-protocol-http/src/api/sso_provider_list.rs b/warpgate-protocol-http/src/api/sso_provider_list.rs index e3e748b53..f2309951f 100644 --- a/warpgate-protocol-http/src/api/sso_provider_list.rs +++ b/warpgate-protocol-http/src/api/sso_provider_list.rs @@ -173,6 +173,7 @@ impl Api { info!("SSO login as {email}"); + let provider = context.provider.clone(); let cred = AuthCredential::Sso { provider: context.provider, email: email.clone(), @@ -210,6 +211,38 @@ impl Api { authorize_session(req, username).await?; } + let providers_config = services.config.lock().await.store.sso_providers.clone(); + let mut iter = providers_config.iter(); + let Some(provider_config) = iter.find(|x| x.name == provider) else { + return Ok(Err(format!("No provider matching {provider}"))); + }; + + let mappings = provider_config.provider.role_mappings(); + if let Some(remote_groups) = response.groups { + // If mappings is not set, all groups are subject to sync + // and names won't be remapped + let managed_role_names = mappings + .as_ref() + .map(|m| m.iter().map(|x| x.1.clone()).collect::>()); + + let active_role_names: Vec<_> = remote_groups + .iter() + .filter_map({ + |r| { + if let Some(ref mappings) = mappings { + mappings.get(r).cloned() + } else { + Some(r.clone()) + } + } + }) + .collect(); + + debug!("SSO role mappings for {username}: active={active_role_names:?}, managed={managed_role_names:?}"); + cp.apply_sso_role_mappings(&username, managed_role_names, active_role_names) + .await?; + } + Ok(Ok(context .next_url .as_deref() diff --git a/warpgate-sso/Cargo.toml b/warpgate-sso/Cargo.toml index e45bba020..67a6d7fe6 100644 --- a/warpgate-sso/Cargo.toml +++ b/warpgate-sso/Cargo.toml @@ -15,3 +15,4 @@ serde_json = "1.0" once_cell = "1.17" jsonwebtoken = "8" data-encoding = "2.3" +futures = "0.3.30" diff --git a/warpgate-sso/src/config.rs b/warpgate-sso/src/config.rs index c4470cbff..7676acd70 100644 --- a/warpgate-sso/src/config.rs +++ b/warpgate-sso/src/config.rs @@ -59,6 +59,7 @@ pub enum SsoInternalProviderConfig { client_secret: ClientSecret, issuer_url: IssuerUrl, scopes: Vec, + role_mappings: Option>, additional_trusted_audiences: Option>, }, } @@ -170,39 +171,45 @@ impl SsoInternalProviderConfig { #[inline] pub fn extra_parameters(&self) -> HashMap { match self { - SsoInternalProviderConfig::Google { .. } - | SsoInternalProviderConfig::Custom { .. } - | SsoInternalProviderConfig::Azure { .. } => HashMap::new(), SsoInternalProviderConfig::Apple { .. } => { let mut map = HashMap::new(); map.insert("response_mode".to_string(), "form_post".to_string()); map } + _ => HashMap::new(), } } #[inline] pub fn auth_type(&self) -> AuthType { + #[allow(clippy::match_like_matches_macro)] match self { - SsoInternalProviderConfig::Google { .. } - | SsoInternalProviderConfig::Custom { .. } - | SsoInternalProviderConfig::Azure { .. } => AuthType::BasicAuth, SsoInternalProviderConfig::Apple { .. } => AuthType::RequestBody, + _ => AuthType::BasicAuth, } } #[inline] pub fn needs_pkce_verifier(&self) -> bool { + #[allow(clippy::match_like_matches_macro)] match self { - SsoInternalProviderConfig::Google { .. } - | SsoInternalProviderConfig::Custom { .. } - | SsoInternalProviderConfig::Azure { .. } => true, SsoInternalProviderConfig::Apple { .. } => false, + _ => true, + } + } + + #[inline] + pub fn role_mappings(&self) -> Option> { + #[allow(clippy::match_like_matches_macro)] + match self { + SsoInternalProviderConfig::Custom { role_mappings, .. } => role_mappings.clone(), + _ => None, } } #[inline] pub fn additional_trusted_audiences(&self) -> Option<&Vec> { + #[allow(clippy::match_like_matches_macro)] match self { SsoInternalProviderConfig::Custom { additional_trusted_audiences, diff --git a/warpgate-sso/src/request.rs b/warpgate-sso/src/request.rs index 774b7c74c..a851a8e96 100644 --- a/warpgate-sso/src/request.rs +++ b/warpgate-sso/src/request.rs @@ -1,10 +1,21 @@ +use futures::future::OptionFuture; +use openidconnect::core::CoreGenderClaim; use openidconnect::reqwest::async_http_client; use openidconnect::url::Url; use openidconnect::{ - AccessTokenHash, AuthorizationCode, CsrfToken, Nonce, OAuth2TokenResponse, PkceCodeVerifier, - RedirectUrl, RequestTokenError, TokenResponse, + AccessTokenHash, AdditionalClaims, AuthorizationCode, CsrfToken, Nonce, OAuth2TokenResponse, + PkceCodeVerifier, RedirectUrl, RequestTokenError, TokenResponse, UserInfoClaims, }; use serde::{Deserialize, Serialize}; +use tracing::{debug, error}; + +#[derive(Debug, Deserialize, Serialize, Clone)] +struct WarpgateClaims { + // This uses the "warpgate_groups" claim from OIDC + warpgate_groups: Option>, +} + +impl AdditionalClaims for WarpgateClaims {} use crate::{make_client, SsoError, SsoInternalProviderConfig, SsoLoginResponse}; @@ -55,6 +66,25 @@ impl SsoLoginRequest { let id_token = token_response.id_token().ok_or(SsoError::NotOidc)?; let claims = id_token.claims(&client.id_token_verifier(), &self.nonce)?; + let user_info_req = client + .user_info(token_response.access_token().to_owned(), None) + .map_err(|err| { + error!("Failed to fetch userinfo: {err:?}"); + err + }) + .ok(); + + let userinfo_claims: Option> = + OptionFuture::from(user_info_req.map(|req| req.request_async(async_http_client))) + .await + .and_then(|res| { + res.map_err(|err| { + error!("Failed to fetch userinfo: {err:?}"); + err + }) + .ok() + }); + if let Some(expected_access_token_hash) = claims.access_token_hash() { let actual_access_token_hash = AccessTokenHash::from_token( token_response.access_token(), @@ -65,14 +95,30 @@ impl SsoLoginRequest { } } + debug!("OIDC claims: {:?}", claims); + debug!("OIDC userinfo claims: {:?}", userinfo_claims); + + macro_rules! get_claim { + ($method:ident) => { + claims + .$method() + .or(userinfo_claims.as_ref().and_then(|x| x.$method())) + }; + } + Ok(SsoLoginResponse { - name: claims - .name() + name: get_claim!(name) .and_then(|x| x.get(None)) .map(|x| x.as_str()) .map(ToString::to_string), - email: claims.email().map(|x| x.as_str()).map(ToString::to_string), - email_verified: claims.email_verified(), + + email: get_claim!(email) + .map(|x| x.as_str()) + .map(ToString::to_string), + + email_verified: get_claim!(email_verified), + + groups: userinfo_claims.and_then(|x| x.additional_claims().warpgate_groups.clone()), }) } } diff --git a/warpgate-sso/src/response.rs b/warpgate-sso/src/response.rs index 962c8081f..e40435812 100644 --- a/warpgate-sso/src/response.rs +++ b/warpgate-sso/src/response.rs @@ -3,4 +3,5 @@ pub struct SsoLoginResponse { pub name: Option, pub email: Option, pub email_verified: Option, + pub groups: Option>, } diff --git a/warpgate-sso/src/sso.rs b/warpgate-sso/src/sso.rs index 67bed666d..f523e66be 100644 --- a/warpgate-sso/src/sso.rs +++ b/warpgate-sso/src/sso.rs @@ -70,9 +70,9 @@ impl SsoClient { } else { None }; - + let (auth_url, csrf_token, nonce) = auth_req.url(); - + Ok(SsoLoginRequest { auth_url, csrf_token, diff --git a/warpgate-web/src/admin/UserCredentialModal.svelte b/warpgate-web/src/admin/UserCredentialModal.svelte index b63958cde..dabac2ab7 100644 --- a/warpgate-web/src/admin/UserCredentialModal.svelte +++ b/warpgate-web/src/admin/UserCredentialModal.svelte @@ -113,7 +113,7 @@ $: { bind:value={credential.provider} type="select" > - + {#each providers as provider} {/each}