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_id>
      client_secret: <client_secret>
      issuer_url: <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 <inbox@null.page>
This commit is contained in:
Skyler Mansfield 2024-03-23 11:05:12 +00:00 committed by GitHub
parent 1395d64eac
commit 916d51a4e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 310 additions and 42 deletions

37
Cargo.lock generated
View file

@ -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",

View file

@ -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"
}
]
}
]

View file

@ -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

View file

@ -13,8 +13,10 @@ pub enum WarpgateError {
InvalidCredentialType,
#[error(transparent)]
Other(Box<dyn Error + Send + Sync>),
#[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}")]

View file

@ -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(

View file

@ -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<Vec<String>>,
assigned_role_names: Vec<String>,
) -> 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(())
}
}

View file

@ -287,4 +287,13 @@ impl ConfigProvider for FileConfigProvider {
Ok(intersect)
}
async fn apply_sso_role_mappings(
&mut self,
_username: &str,
_managed_role_names: Option<Vec<String>>,
_assigned_role_names: Vec<String>,
) -> Result<(), WarpgateError> {
Ok(())
}
}

View file

@ -31,6 +31,13 @@ pub trait ConfigProvider {
client_credential: &AuthCredential,
) -> Result<Option<String>, WarpgateError>;
async fn apply_sso_role_mappings(
&mut self,
username: &str,
managed_role_names: Option<Vec<String>>,
active_role_names: Vec<String>,
) -> Result<(), WarpgateError>;
async fn get_credential_policy(
&mut self,
username: &str,

View file

@ -130,7 +130,7 @@ impl Api {
)
.await
{
Err(WarpgateError::UserNotFound) => {
Err(WarpgateError::UserNotFound(_)) => {
return Ok(LoginResponse::Failure(Json(LoginFailureResponse {
state: ApiAuthState::Failed,
})))

View file

@ -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::<Vec<_>>());
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()

View file

@ -15,3 +15,4 @@ serde_json = "1.0"
once_cell = "1.17"
jsonwebtoken = "8"
data-encoding = "2.3"
futures = "0.3.30"

View file

@ -59,6 +59,7 @@ pub enum SsoInternalProviderConfig {
client_secret: ClientSecret,
issuer_url: IssuerUrl,
scopes: Vec<String>,
role_mappings: Option<HashMap<String, String>>,
additional_trusted_audiences: Option<Vec<String>>,
},
}
@ -170,39 +171,45 @@ impl SsoInternalProviderConfig {
#[inline]
pub fn extra_parameters(&self) -> HashMap<String, String> {
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<HashMap<String, String>> {
#[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<String>> {
#[allow(clippy::match_like_matches_macro)]
match self {
SsoInternalProviderConfig::Custom {
additional_trusted_audiences,

View file

@ -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<Vec<String>>,
}
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<UserInfoClaims<WarpgateClaims, CoreGenderClaim>> =
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()),
})
}
}

View file

@ -3,4 +3,5 @@ pub struct SsoLoginResponse {
pub name: Option<String>,
pub email: Option<String>,
pub email_verified: Option<bool>,
pub groups: Option<Vec<String>>,
}

View file

@ -113,7 +113,7 @@ $: {
bind:value={credential.provider}
type="select"
>
<option value="" selected>Any</option>
<option value={null} selected>Any</option>
{#each providers as provider}
<option value={provider.name}>{provider.label ?? provider.name}</option>
{/each}