From b76872febe7335db332148335c241bd009544dda Mon Sep 17 00:00:00 2001 From: Eugene Date: Mon, 10 Feb 2025 01:12:50 +0100 Subject: [PATCH] fixed #952 - auto create sso users (#1245) --- oidc-test/clients-config.json | 39 --------- oidc-test/docker-compose.yml | 80 ------------------- tests/oidc-mock/clients-config.json | 78 +++++++++++++----- tests/oidc-mock/docker-compose.yml | 26 +++--- warpgate-core/src/config_providers/db.rs | 80 +++++++++++++++---- warpgate-core/src/config_providers/mod.rs | 3 + .../src/api/sso_provider_list.rs | 19 +++-- warpgate-sso/src/config.rs | 2 + warpgate-sso/src/request.rs | 4 + warpgate-sso/src/response.rs | 1 + warpgate-web/src/gateway/Login.svelte | 6 +- 11 files changed, 155 insertions(+), 183 deletions(-) delete mode 100644 oidc-test/clients-config.json delete mode 100644 oidc-test/docker-compose.yml diff --git a/oidc-test/clients-config.json b/oidc-test/clients-config.json deleted file mode 100644 index 3268c7b3..00000000 --- a/oidc-test/clients-config.json +++ /dev/null @@ -1,39 +0,0 @@ -[ - { - "ClientId": "implicit-mock-client", - "Description": "Client for implicit flow", - "AllowedGrantTypes": ["implicit"], - "AllowAccessTokensViaBrowser": true, - "RedirectUris": [ - "https://warpgate.com/@warpgate/api/sso/return", - "https://127.0.0.1:8888/@warpgate/api/sso/return" - ], - "AllowedScopes": ["openid", "profile", "email", "warpgate-scope"], - "IdentityTokenLifetime": 3600, - "AccessTokenLifetime": 3600 - }, - { - "ClientId": "client-credentials-mock-client", - "ClientSecrets": ["client-credentials-mock-client-secret"], - "Description": "Client for client credentials flow", - "AllowedGrantTypes": ["authorization_code"], - "AllowedScopes": ["openid", "profile", "email", "warpgate-scope"], - "ClientClaimsPrefix": "", - "RedirectUris": [ - "https://warpgate.com/@warpgate/api/sso/return", - "https://127.0.0.1:8888/@warpgate/api/sso/return" - ], - "Claims": [ - { - "Type": "string_claim", - "Value": "string_claim_value", - "ValueType": "string" - }, - { - "Type": "json_claim", - "Value": "[\"value1\", \"value2\"]", - "ValueType": "json" - } - ] - } - ] diff --git a/oidc-test/docker-compose.yml b/oidc-test/docker-compose.yml deleted file mode 100644 index 2b3a8862..00000000 --- a/oidc-test/docker-compose.yml +++ /dev/null @@ -1,80 +0,0 @@ -version: '3' -services: - oidc-server-mock: - container_name: oidc-server-mock - image: ghcr.io/soluto/oidc-server-mock:latest - ports: - - '4011:8080' - 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 - API_RESOURCES_INLINE: | - - Name: some-app - Scopes: - - some-app-scope-1 - - some-app-scope-2 - 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": "some-api-resource-claim", - "Value": "Sam's Api Resource Custom Claim", - "ValueType": "string" - }, - { - "Type": "some-api-scope-claim", - "Value": "Sam's Api Scope Custom Claim", - "ValueType": "string" - }, - { - "Type": "some-identity-resource-claim", - "Value": "Sam's Identity Resource Custom Claim", - "ValueType": "string" - } - ] - } - ] - CLIENTS_CONFIGURATION_PATH: /tmp/config/clients-config.json - ASPNET_SERVICES_OPTIONS_INLINE: | - { - "ForwardedHeadersOptions": { - "ForwardedHeaders" : "All" - } - } - volumes: - - .:/tmp/config:ro diff --git a/tests/oidc-mock/clients-config.json b/tests/oidc-mock/clients-config.json index 41e061c8..e36c1682 100644 --- a/tests/oidc-mock/clients-config.json +++ b/tests/oidc-mock/clients-config.json @@ -1,23 +1,57 @@ [ - { - "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" - } - ] - } - ] + { + "ClientId": "implicit-mock-client", + "Description": "Client for implicit flow", + "AllowedGrantTypes": [ + "implicit" + ], + "AllowAccessTokensViaBrowser": true, + "RedirectUris": [ + "https://warpgate.com/@warpgate/api/sso/return", + "https://127.0.0.1:8888/@warpgate/api/sso/return" + ], + "AllowedScopes": [ + "openid", + "profile", + "email", + "warpgate-scope", + "preferred_username" + ], + "IdentityTokenLifetime": 3600, + "AccessTokenLifetime": 3600 + }, + { + "ClientId": "client-credentials-mock-client", + "ClientSecrets": [ + "client-credentials-mock-client-secret" + ], + "Description": "Client for client credentials flow", + "AllowedGrantTypes": [ + "authorization_code" + ], + "AllowedScopes": [ + "openid", + "profile", + "email", + "warpgate-scope", + "preferred_username" + ], + "ClientClaimsPrefix": "", + "RedirectUris": [ + "https://warpgate.com/@warpgate/api/sso/return", + "https://127.0.0.1:8888/@warpgate/api/sso/return" + ], + "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 index 737211dd..dde8bced 100644 --- a/tests/oidc-mock/docker-compose.yml +++ b/tests/oidc-mock/docker-compose.yml @@ -2,9 +2,10 @@ version: '3' services: oidc-server-mock: container_name: oidc-server-mock - image: ghcr.io/soluto/oidc-server-mock:latest + image: ghcr.io/soluto/oidc-server-mock:0.10.1 + platform: linux/amd64 ports: - - '4011:80' + - '4011:8080' environment: ASPNETCORE_ENVIRONMENT: Development SERVER_OPTIONS_INLINE: | @@ -20,23 +21,16 @@ services: } LOGIN_OPTIONS_INLINE: | { - "AllowRememberLogin": false + "AllowRememberLogin": true } LOGOUT_OPTIONS_INLINE: | { "AutomaticRedirectAfterSignOut": true } - API_SCOPES_INLINE: | - - Name: some-app-scope-1 - - Name: some-app-scope-2 IDENTITY_RESOURCES_INLINE: | - - Name: warpgate-scope + - Name: preferred_username ClaimTypes: - - warpgate_roles - # API_RESOURCES_INLINE: | - # - Name: wapgate_groups - # Scopes: - # - warpgate + - preferred_username USERS_CONFIGURATION_INLINE: | [ { @@ -55,10 +49,10 @@ services: "ValueType": "string" }, { - "Type": "warpgate_roles", - "Value": "[\"qa\", \"unknown\"]", - "ValueType": "json" - } + "Type": "preferred_username", + "Value": "sam_tailor", + "ValueType": "string" + }, ] } ] diff --git a/warpgate-core/src/config_providers/db.rs b/warpgate-core/src/config_providers/db.rs index 25145e99..856b734b 100644 --- a/warpgate-core/src/config_providers/db.rs +++ b/warpgate-core/src/config_providers/db.rs @@ -9,6 +9,7 @@ use sea_orm::{ }; use tokio::sync::Mutex; use tracing::*; +use uuid::Uuid; use warpgate_common::auth::{ AllCredentialsPolicy, AnySingleCredentialPolicy, AuthCredential, CredentialKind, CredentialPolicy, PerProtocolCredentialPolicy, @@ -17,9 +18,10 @@ use warpgate_common::helpers::hash::verify_password_hash; use warpgate_common::helpers::otp::verify_totp; use warpgate_common::{ Role, Target, User, UserAuthCredential, UserPasswordCredential, UserPublicKeyCredential, - UserSsoCredential, UserTotpCredential, WarpgateError, + UserRequireCredentialsPolicy, UserSsoCredential, UserTotpCredential, WarpgateError, }; use warpgate_db_entities as entities; +use warpgate_sso::SsoProviderConfig; use super::ConfigProvider; @@ -31,6 +33,33 @@ impl DatabaseConfigProvider { pub async fn new(db: &Arc>) -> Self { Self { db: db.clone() } } + + async fn maybe_autocreate_sso_user( + &self, + db: &DatabaseConnection, + credential: UserSsoCredential, + preferred_username: String, + ) -> Result, WarpgateError> { + let user = entities::User::ActiveModel { + id: Set(Uuid::new_v4()), + username: Set(preferred_username.clone()), + credential_policy: Set(serde_json::to_value( + UserRequireCredentialsPolicy::default(), + )?), + } + .insert(&*db) + .await?; + + entities::SsoCredential::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user.id), + ..entities::SsoCredential::ActiveModel::from(credential) + } + .insert(&*db) + .await?; + + Ok(Some(preferred_username)) + } } impl ConfigProvider for DatabaseConfigProvider { @@ -143,6 +172,8 @@ impl ConfigProvider for DatabaseConfigProvider { async fn username_for_sso_credential( &mut self, client_credential: &AuthCredential, + preferred_username: Option, + sso_config: SsoProviderConfig, ) -> Result, WarpgateError> { let db = self.db.lock().await; @@ -154,7 +185,7 @@ impl ConfigProvider for DatabaseConfigProvider { return Ok(None); }; - let Some(cred) = entities::SsoCredential::Entity::find() + let cred = entities::SsoCredential::Entity::find() .filter( entities::SsoCredential::Column::Email.eq(client_email).and( entities::SsoCredential::Column::Provider @@ -163,14 +194,34 @@ impl ConfigProvider for DatabaseConfigProvider { ), ) .one(&*db) - .await? - else { - return Ok(None); - }; + .await?; - let user = cred.find_related(entities::User::Entity).one(&*db).await?; + if let Some(cred) = cred { + let user = cred.find_related(entities::User::Entity).one(&*db).await?; - Ok(user.map(|x| x.username)) + if let Some(user) = user { + return Ok(Some(user.username.clone())); + } + } + + if sso_config.auto_create_users { + let Some(preferred_username) = preferred_username else { + error!("The OIDC server did not provide a preferred_username claim for this user"); + return Ok(None); + }; + return Ok(self + .maybe_autocreate_sso_user( + &*db, + UserSsoCredential { + email: client_email.clone(), + provider: Some(client_provider.clone()), + }, + preferred_username, + ) + .await?); + } + + Ok(None) } async fn validate_credential( @@ -330,8 +381,7 @@ impl ConfigProvider for DatabaseConfigProvider { let user = entities::User::Entity::find() .filter(entities::User::Column::Username.eq(username)) .one(&*db) - .await - .map_err(WarpgateError::from)? + .await? .ok_or_else(|| WarpgateError::UserNotFound(username.into()))?; let managed_role_names = match managed_role_names { @@ -348,16 +398,14 @@ impl ConfigProvider for DatabaseConfigProvider { let role = entities::Role::Entity::find() .filter(entities::Role::Column::Name.eq(role_name.clone())) .one(&*db) - .await - .map_err(WarpgateError::from)? + .await? .ok_or_else(|| WarpgateError::RoleNotFound(role_name.clone()))?; let assignment = entities::UserRoleAssignment::Entity::find() .filter(entities::UserRoleAssignment::Column::UserId.eq(user.id)) .filter(entities::UserRoleAssignment::Column::RoleId.eq(role.id)) .one(&*db) - .await - .map_err(WarpgateError::from)?; + .await?; match (assignment, assigned_role_names.contains(&role_name)) { (None, true) => { @@ -368,11 +416,11 @@ impl ConfigProvider for DatabaseConfigProvider { ..Default::default() }; - values.insert(&*db).await.map_err(WarpgateError::from)?; + values.insert(&*db).await?; } (Some(assignment), false) => { info!("Removing role {role_name} for user {username} (from SSO)"); - assignment.delete(&*db).await.map_err(WarpgateError::from)?; + assignment.delete(&*db).await?; } _ => (), } diff --git a/warpgate-core/src/config_providers/mod.rs b/warpgate-core/src/config_providers/mod.rs index b40fdeea..399057f2 100644 --- a/warpgate-core/src/config_providers/mod.rs +++ b/warpgate-core/src/config_providers/mod.rs @@ -11,6 +11,7 @@ use uuid::Uuid; use warpgate_common::auth::{AuthCredential, CredentialKind, CredentialPolicy}; use warpgate_common::{Secret, Target, User, WarpgateError}; use warpgate_db_entities::Ticket; +use warpgate_sso::SsoProviderConfig; #[enum_dispatch] pub enum ConfigProviderEnum { @@ -33,6 +34,8 @@ pub trait ConfigProvider { async fn username_for_sso_credential( &mut self, client_credential: &AuthCredential, + preferred_username: Option, + sso_config: SsoProviderConfig, ) -> Result, WarpgateError>; async fn apply_sso_role_mappings( diff --git a/warpgate-protocol-http/src/api/sso_provider_list.rs b/warpgate-protocol-http/src/api/sso_provider_list.rs index d1193540..2e922620 100644 --- a/warpgate-protocol-http/src/api/sso_provider_list.rs +++ b/warpgate-protocol-http/src/api/sso_provider_list.rs @@ -192,7 +192,12 @@ impl Api { info!("SSO login as {email}"); - let provider = context.provider.clone(); + 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 == context.provider) else { + return Ok(Err(format!("No provider matching {}", context.provider))); + }; + let cred = AuthCredential::Sso { provider: context.provider.clone(), email: email.clone(), @@ -202,7 +207,11 @@ impl Api { .config_provider .lock() .await - .username_for_sso_credential(&cred) + .username_for_sso_credential( + &cred, + response.preferred_username, + provider_config.clone(), + ) .await?; let Some(username) = username else { return Ok(Err(format!("No user matching {email}"))); @@ -239,12 +248,6 @@ impl Api { }); } - 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 diff --git a/warpgate-sso/src/config.rs b/warpgate-sso/src/config.rs index dfd36419..600e6015 100644 --- a/warpgate-sso/src/config.rs +++ b/warpgate-sso/src/config.rs @@ -22,6 +22,8 @@ pub struct SsoProviderConfig { pub label: Option, pub provider: SsoInternalProviderConfig, pub return_domain_whitelist: Option>, + #[serde(default)] + pub auto_create_users: bool, } impl SsoProviderConfig { diff --git a/warpgate-sso/src/request.rs b/warpgate-sso/src/request.rs index 59de358c..29af4234 100644 --- a/warpgate-sso/src/request.rs +++ b/warpgate-sso/src/request.rs @@ -42,6 +42,10 @@ impl SsoLoginRequest { } Ok(SsoLoginResponse { + preferred_username: get_claim!(preferred_username) + .map(|x| x.as_str()) + .map(ToString::to_string), + name: get_claim!(name) .and_then(|x| x.get(None)) .map(|x| x.as_str()) diff --git a/warpgate-sso/src/response.rs b/warpgate-sso/src/response.rs index 11d666dd..8cad8f78 100644 --- a/warpgate-sso/src/response.rs +++ b/warpgate-sso/src/response.rs @@ -7,4 +7,5 @@ pub struct SsoLoginResponse { pub email_verified: Option, pub groups: Option>, pub id_token: CoreIdToken, + pub preferred_username: Option, } diff --git a/warpgate-web/src/gateway/Login.svelte b/warpgate-web/src/gateway/Login.svelte index f3f04b36..2c45c2a0 100644 --- a/warpgate-web/src/gateway/Login.svelte +++ b/warpgate-web/src/gateway/Login.svelte @@ -194,8 +194,10 @@ Login - {/if} + +
+ {#if authState === ApiAuthState.Failed} Incorrect credentials {/if} @@ -210,7 +212,7 @@ {#if authState === ApiAuthState.SsoNeeded || authState === ApiAuthState.NotStarted || authState === ApiAuthState.Failed} {#snippet children(ssoProviders)} -
+
{#each ssoProviders as ssoProvider}