diff --git a/src/WebApi/Authentication/JwtOptions.cs b/src/WebApi/Authentication/JwtOptions.cs index 01e8223..ae44dbe 100644 --- a/src/WebApi/Authentication/JwtOptions.cs +++ b/src/WebApi/Authentication/JwtOptions.cs @@ -11,6 +11,8 @@ public class JwtOptions public string MetadataUrl { get; set; } = ""; public string? NameClaimType { get; set; } + + public bool AllowHttpMetadataUrl { get; set; } } public class JwtOptionsValidator : AbstractValidator diff --git a/src/WebApi/Common/DependencyInjectionExtensions.cs b/src/WebApi/Common/DependencyInjectionExtensions.cs index bab1a0d..7e09813 100644 --- a/src/WebApi/Common/DependencyInjectionExtensions.cs +++ b/src/WebApi/Common/DependencyInjectionExtensions.cs @@ -18,14 +18,16 @@ public static class DependencyInjectionExtensions /// Adds all the WebApi related services to the Dependency Injection container. /// /// - public static void AddWebApiServices(this IServiceCollection services, IConfiguration configuration) + /// + /// + public static void AddWebApiServices(this IServiceCollection services, IConfiguration configuration, IHostEnvironment environment) { services .AddMiscellaneousServices() .AddOpenApi() .AddApiVersioning() .AddOtel() - .AddAuthenticationAndAuthorization() + .AddAuthenticationAndAuthorization(environment) .AddDbContext(configuration); } @@ -113,7 +115,7 @@ public static class DependencyInjectionExtensions return services; } - private static IServiceCollection AddAuthenticationAndAuthorization(this IServiceCollection services) + private static IServiceCollection AddAuthenticationAndAuthorization(this IServiceCollection services, IHostEnvironment environment) { services.AddOptions() .BindConfiguration(JwtOptions.SectionName) @@ -134,6 +136,8 @@ public static class DependencyInjectionExtensions { o.TokenValidationParameters.NameClaimType = jwtOptions.Value.NameClaimType; } + + o.RequireHttpsMetadata = !jwtOptions.Value.AllowHttpMetadataUrl && !environment.IsDevelopment(); }); services.AddAuthorizationBuilder() diff --git a/src/WebApi/Common/StartupExtensions.cs b/src/WebApi/Common/StartupExtensions.cs index 4b6b07d..351875f 100644 --- a/src/WebApi/Common/StartupExtensions.cs +++ b/src/WebApi/Common/StartupExtensions.cs @@ -11,7 +11,7 @@ internal static class StartupExtensions { builder.Configuration.AddEnvironmentVariables("Vegasco_"); - builder.Services.AddWebApiServices(builder.Configuration); + builder.Services.AddWebApiServices(builder.Configuration, builder.Environment); WebApplication app = builder.Build(); return app; diff --git a/tests/WebApi.Tests.System/ComposeService.cs b/tests/WebApi.Tests.System/ComposeService.cs new file mode 100644 index 0000000..05483c4 --- /dev/null +++ b/tests/WebApi.Tests.System/ComposeService.cs @@ -0,0 +1,166 @@ +using Ductus.FluentDocker.Services; +using Ductus.FluentDocker.Services.Extensions; +using System.Diagnostics.CodeAnalysis; + +namespace WebApi.Tests.System; + +public abstract class ComposeService : IDisposable +{ + public string ServiceName { get; init; } + public string ServiceInternalPort { get; init; } + public string ServiceInternalProtocol { get; init; } + public string ServiceInternalPortAndProtocol => $"{ServiceInternalPort}/{ServiceInternalProtocol}"; + + private readonly ICompositeService _dockerService; + private readonly bool _isTestRunningInContainer; + + private IContainerService? _container; + private bool _hasCheckedForContainer; + + /// + /// Not null, if is true. + /// + public IContainerService? Container + { + get + { + if (_hasCheckedForContainer) + { + return _container; + } + + _container ??= _dockerService.Containers.First(x => x.Name == ServiceName); + _hasCheckedForContainer = true; + + return _container; + } + } + + [MemberNotNullWhen(returnValue: true, nameof(Container))] + public bool ContainerExists => Container is not null; + + public ComposeService( + ICompositeService dockerService, + bool isTestRunningInContainer, + string serviceName, + string serviceInternalPort, + string serviceInternalProtocol = "tcp") + { + _dockerService = dockerService; + _isTestRunningInContainer = isTestRunningInContainer; + ServiceName = serviceName; + ServiceInternalPort = serviceInternalPort; + ServiceInternalProtocol = serviceInternalProtocol; + } + + public string? GetServiceUrl() + { + if (!ContainerExists) + { + return null; + } + + return _isTestRunningInContainer + ? GetServiceUrlWhenRunningInsideContainer() + : GetUrlFromOutsideContainer(Container, ServiceInternalPortAndProtocol); + } + + private string GetServiceUrlWhenRunningInsideContainer() + { + return $"http://{ServiceName}:{ServiceInternalPort}"; + } + + private static string GetUrlFromOutsideContainer(IContainerService container, string portAndProto) + { + var ipEndpoint = container.ToHostExposedEndpoint(portAndProto); + return $"http://{ipEndpoint.Address}:{ipEndpoint.Port}"; + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _container?.Dispose(); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } +} + +public sealed class AppContainer : ComposeService +{ + public AppContainer(ICompositeService dockerService, bool isTestRunningInContainer) + : base(dockerService, isTestRunningInContainer, "app", "8080") + { + } +} + +public sealed class LoginContainer : ComposeService +{ + public LoginContainer(ICompositeService dockerService, bool isTestRunningInContainer) + : base(dockerService, isTestRunningInContainer, "login", "8080") + { + } +} + + +public sealed class TestAppContainer : IDisposable +{ + private IContainerService? _testApplicationContainer; + private bool _hasCheckedForThisContainer; + public IContainerService? Container + { + get + { + if (!_hasCheckedForThisContainer) + { + _testApplicationContainer = _dockerHost.GetRunningContainers() + .FirstOrDefault(x => x.Id.StartsWith(Environment.MachineName)); + + if (_testApplicationContainer is not null) + { + // If the test is running inside a container (i.e. usually in a pipeline), we do not want to mess with the container, just release the resources held by this program + _testApplicationContainer.RemoveOnDispose = false; + _testApplicationContainer.StopOnDispose = false; + } + + _hasCheckedForThisContainer = true; + } + + return _testApplicationContainer; + } + } + + private bool _hasCheckedIfTestRunInContainer; + private bool _isTestRunningInContainer; + public bool IsTestRunningInContainer + { + get + { + if (!_hasCheckedIfTestRunInContainer) + { + _isTestRunningInContainer = Container is not null; + _hasCheckedIfTestRunInContainer = true; + } + + return _isTestRunningInContainer; + } + } + + + private readonly IHostService _dockerHost; + + public TestAppContainer(IHostService dockerHost) + { + _dockerHost = dockerHost; + } + + public void Dispose() + { + _testApplicationContainer?.Dispose(); + } +} \ No newline at end of file diff --git a/tests/WebApi.Tests.System/Constants.cs b/tests/WebApi.Tests.System/Constants.cs new file mode 100644 index 0000000..4f05ff6 --- /dev/null +++ b/tests/WebApi.Tests.System/Constants.cs @@ -0,0 +1,13 @@ +namespace WebApi.Tests.System; + +public static class Constants +{ + public static class Login + { + public const string ClientId = "vegasco"; + public const string ClientSecret = "siIgnkijkkIxeQ9BDNwnGGUb60S53QZh"; + public const string Username = "test.user"; + public const string Password = "T3sttest."; + public const string Realm = "development"; + } +} \ No newline at end of file diff --git a/tests/WebApi.Tests.System/DockerComposeFixture.cs b/tests/WebApi.Tests.System/DockerComposeFixture.cs new file mode 100644 index 0000000..8922f73 --- /dev/null +++ b/tests/WebApi.Tests.System/DockerComposeFixture.cs @@ -0,0 +1,301 @@ +using Ductus.FluentDocker.Builders; +using Ductus.FluentDocker.Extensions; +using Ductus.FluentDocker.Model.Common; +using Ductus.FluentDocker.Model.Compose; +using Ductus.FluentDocker.Services; +using Ductus.FluentDocker.Services.Extensions; + +namespace WebApi.Tests.System; + +public sealed class DockerComposeFixture : IDisposable +{ + private const string ComposeFileName = "compose.system.yaml"; + + private const string AppServiceName = "app"; + private const string AppServiceInternalPort = "8080"; + private const string AppServiceInternalPortAndProtocol = $"{AppServiceInternalPort}/tcp"; + + private const string LoginServiceName = "login"; + private const string LoginServiceInternalPort = "8080"; + private const string LoginServiceInternalPortAndProtocol = $"{LoginServiceInternalPort}/tcp"; + + private static readonly string ComposeFilePath = Path.GetFullPath(Path.Combine("../../..", ComposeFileName)); + + private readonly ICompositeService _dockerService; + + + private bool _hasCheckedForThisContainer; + private bool _hasCheckedIfTestRunInContainer; + + private IHostService? _host; + + private bool _isTestRunningInContainer; + private INetworkService? _networkService; + private IContainerService? _testApplicationContainer; + + public DockerComposeFixture() + { + _dockerService = GetDockerComposeServices(); + _dockerService.Start(); + AttachDockerNetworksIfRunningInContainer(); + } + + private IContainerService? _appContainer; + public IContainerService AppContainer + { + get + { + _appContainer ??= _dockerService.Containers.First(x => x.Name == AppServiceName); + return _appContainer; + } + } + + private IContainerService? _loginContainer; + public IContainerService LoginContainer + { + get + { + _loginContainer ??= _dockerService.Containers.First(x => x.Name == LoginServiceName); + return _loginContainer; + } + } + + public IContainerService? TestApplicationContainer + { + get + { + if (!_hasCheckedForThisContainer) + { + _testApplicationContainer = DockerHost.GetRunningContainers() + .FirstOrDefault(x => x.Id.StartsWith(Environment.MachineName)); + + if (_testApplicationContainer is not null) + { + // If the test is running inside a container (i.e. usually in a pipeline), we do not want to mess with the container, just release the resources held by this program + _testApplicationContainer.RemoveOnDispose = false; + _testApplicationContainer.StopOnDispose = false; + } + + _hasCheckedForThisContainer = true; + } + + return _testApplicationContainer; + } + } + + public IHostService DockerHost + { + get + { + var hosts = new Hosts().Discover(); + _host = hosts.FirstOrDefault(x => x.IsNative) ?? + hosts.FirstOrDefault(x => x.Name == "default") ?? + hosts.FirstOrDefault(); + + if (_host is null) throw new InvalidOperationException("No docker host found"); + + return _host; + } + } + + public bool IsTestRunningInContainer + { + get + { + if (!_hasCheckedIfTestRunInContainer) + { + _isTestRunningInContainer = TestApplicationContainer is not null; + _hasCheckedIfTestRunInContainer = true; + } + + return _isTestRunningInContainer; + } + } + + public void Dispose() + { + _networkService?.Dispose(); + + _testApplicationContainer?.Dispose(); + _appContainer?.Dispose(); + _loginContainer?.Dispose(); + + // Kill container because otherwise the _dockerService.Dispose() takes much longer + KillDockerComposeServices(); + + _dockerService.Dispose(); + + _host?.Dispose(); + } + + private ICompositeService GetDockerComposeServices() + { + var services = new Builder() + .UseContainer() + .UseCompose() + .AssumeComposeVersion(ComposeVersion.V2) + .FromFile((TemplateString)ComposeFilePath) + .ForceBuild() + .RemoveOrphans() + .Wait("app", WaitForApplicationToListenToRequests) + .Build(); + + return services; + } + + private int WaitForApplicationToListenToRequests(IContainerService container, int iteration) + { + const int maxTryCount = 15; + ArgumentOutOfRangeException.ThrowIfGreaterThan(iteration, maxTryCount); + + var isStarted = container.Logs().ReadToEnd().Reverse().Any(x => x.Contains("Now listening on:")); + return isStarted ? 0 : 500; + } + + private void AttachDockerNetworksIfRunningInContainer() + { + if (!IsTestRunningInContainer) return; + + var randomNetworkName = Guid.NewGuid().ToString("N"); + _networkService = DockerHost.CreateNetwork(randomNetworkName, removeOnDispose: true); + + _networkService.Attach(AppContainer, true, AppServiceName); + _networkService.Attach(TestApplicationContainer, true); + } + + public string GetAppUrl() + { + return IsTestRunningInContainer + ? GetAppUrlWhenRunningInsideContainer() + : GetUrlFromOutsideContainer(AppContainer, AppServiceInternalPortAndProtocol); + } + + private static string GetAppUrlWhenRunningInsideContainer() + { + return $"http://{AppServiceName}:{AppServiceInternalPort}"; + } + + public string GetLoginUrl() + { + return IsTestRunningInContainer + ? GetLoginUrlWhenRunningInsideContainer() + : GetUrlFromOutsideContainer(LoginContainer, LoginServiceInternalPortAndProtocol); + } + + private static string GetLoginUrlWhenRunningInsideContainer() + { + return $"http://{LoginServiceName}:{LoginServiceInternalPort}"; + } + + private static string GetUrlFromOutsideContainer(IContainerService container, string portAndProto) + { + var ipEndpoint = container.ToHostExposedEndpoint(portAndProto); + return $"http://{ipEndpoint.Address}:{ipEndpoint.Port}"; + } + + private void KillDockerComposeServices() + { + foreach (var container in _dockerService.Containers) container.Remove(true); + } +} + + +public sealed class DockerComposeFixture2 : IDisposable +{ + private const string ComposeFileName = "compose.system.yaml"; + + private static readonly string ComposeFilePath = Path.GetFullPath(Path.Combine("../../..", ComposeFileName)); + + private readonly ICompositeService _dockerService; + + private IHostService? _host; + + private INetworkService? _networkService; + + public AppContainer AppContainer { get; init; } + public LoginContainer LoginContainer { get; init; } + public TestAppContainer TestApplicationContainer { get; init; } + + public DockerComposeFixture2() + { + _dockerService = GetDockerComposeServices(); + _dockerService.Start(); + + TestApplicationContainer = new TestAppContainer(DockerHost); + AppContainer = new AppContainer(_dockerService, TestApplicationContainer.IsTestRunningInContainer); + LoginContainer = new LoginContainer(_dockerService, TestApplicationContainer.IsTestRunningInContainer); + + AttachDockerNetworksIfRunningInContainer(); + } + + public IHostService DockerHost + { + get + { + var hosts = new Hosts().Discover(); + _host = hosts.FirstOrDefault(x => x.IsNative) ?? + hosts.FirstOrDefault(x => x.Name == "default") ?? + hosts.FirstOrDefault(); + + if (_host is null) throw new InvalidOperationException("No docker host found"); + + return _host; + } + } + + public void Dispose() + { + _networkService?.Dispose(); + + TestApplicationContainer.Dispose(); + AppContainer.Dispose(); + LoginContainer.Dispose(); + + // Kill container because otherwise the _dockerService.Dispose() takes much longer + KillDockerComposeServices(); + + _dockerService.Dispose(); + + _host?.Dispose(); + } + + private ICompositeService GetDockerComposeServices() + { + var services = new Builder() + .UseContainer() + .UseCompose() + .AssumeComposeVersion(ComposeVersion.V2) + .FromFile((TemplateString)ComposeFilePath) + .ForceBuild() + .RemoveOrphans() + .Wait("app", WaitForApplicationToListenToRequests) + .Build(); + + return services; + } + + private int WaitForApplicationToListenToRequests(IContainerService container, int iteration) + { + const int maxTryCount = 15; + ArgumentOutOfRangeException.ThrowIfGreaterThan(iteration, maxTryCount); + + var isStarted = container.Logs().ReadToEnd().Reverse().Any(x => x.Contains("Now listening on:")); + return isStarted ? 0 : 500; + } + + private void AttachDockerNetworksIfRunningInContainer() + { + if (!TestApplicationContainer.IsTestRunningInContainer) return; + + var randomNetworkName = Guid.NewGuid().ToString("N"); + _networkService = DockerHost.CreateNetwork(randomNetworkName, removeOnDispose: true); + + _networkService.Attach(AppContainer.Container!, true, AppContainer.ServiceName); + _networkService.Attach(TestApplicationContainer.Container, true); + } + + private void KillDockerComposeServices() + { + foreach (var container in _dockerService.Containers) container.Remove(true); + } +} \ No newline at end of file diff --git a/tests/WebApi.Tests.System/SharedTestCollection.cs b/tests/WebApi.Tests.System/SharedTestCollection.cs new file mode 100644 index 0000000..94787b7 --- /dev/null +++ b/tests/WebApi.Tests.System/SharedTestCollection.cs @@ -0,0 +1,7 @@ +namespace WebApi.Tests.System; + +[CollectionDefinition(Name)] +public class SharedTestCollection : ICollectionFixture +{ + public const string Name = nameof(SharedTestCollection); +} diff --git a/tests/WebApi.Tests.System/SharedTestContext.cs b/tests/WebApi.Tests.System/SharedTestContext.cs new file mode 100644 index 0000000..aca0b6d --- /dev/null +++ b/tests/WebApi.Tests.System/SharedTestContext.cs @@ -0,0 +1,11 @@ +namespace WebApi.Tests.System; + +public sealed class SharedTestContext : IDisposable +{ + public DockerComposeFixture2 DockerComposeFixture { get; set; } = new(); + + public void Dispose() + { + DockerComposeFixture.Dispose(); + } +} diff --git a/tests/WebApi.Tests.System/Test.cs b/tests/WebApi.Tests.System/Test.cs new file mode 100644 index 0000000..38aaf15 --- /dev/null +++ b/tests/WebApi.Tests.System/Test.cs @@ -0,0 +1,57 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace WebApi.Tests.System; + +[Collection(SharedTestCollection.Name)] +public class Test +{ + private readonly SharedTestContext _context; + + public Test(SharedTestContext context) + { + _context = context; + } + + //[Fact] + public async Task Test1() + { + var loginUrl = _context.DockerComposeFixture.LoginContainer.GetServiceUrl(); + var baseUrl = new Uri(loginUrl!, UriKind.Absolute); + var relativeUrl = new Uri($"/realms/{Constants.Login.Realm}/protocol/openid-connect/token", UriKind.Relative); + var uri = new Uri(baseUrl, relativeUrl); + var request = new HttpRequestMessage(HttpMethod.Post, uri); + + var data = new Dictionary + { + { "grant_type", "password" }, + { "audience", Constants.Login.ClientId }, + { "username", Constants.Login.Username }, + { "password", Constants.Login.Password } + }; + request.Content = new FormUrlEncodedContent(data); + + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Constants.Login.ClientId}:{Constants.Login.ClientSecret}"))); + + using var client = new HttpClient(); + using var response = await client.SendAsync(request); + + var content = await response.Content.ReadAsStringAsync(); + var tokenResponse = JsonSerializer.Deserialize(content); + + var appUrl = _context.DockerComposeFixture.AppContainer.GetServiceUrl(); + baseUrl = new Uri(appUrl!, UriKind.Absolute); + relativeUrl = new Uri("/v1/cars", UriKind.Relative); + uri = new Uri(baseUrl, relativeUrl); + + request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponse!.AccessToken); + + using var response2 = await client.SendAsync(request); + + var content2 = await response2.Content.ReadAsStringAsync(); + + } +} \ No newline at end of file diff --git a/tests/WebApi.Tests.System/TokenResponse.cs b/tests/WebApi.Tests.System/TokenResponse.cs new file mode 100644 index 0000000..58b157a --- /dev/null +++ b/tests/WebApi.Tests.System/TokenResponse.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace WebApi.Tests.System; + +public class TokenResponse +{ + [JsonPropertyName("access_token")] + public required string AccessToken { get; init; } +} \ No newline at end of file diff --git a/tests/WebApi.Tests.System/WebApi.Tests.System.csproj b/tests/WebApi.Tests.System/WebApi.Tests.System.csproj index 9c5b30a..2e0f217 100644 --- a/tests/WebApi.Tests.System/WebApi.Tests.System.csproj +++ b/tests/WebApi.Tests.System/WebApi.Tests.System.csproj @@ -11,6 +11,7 @@ + diff --git a/tests/WebApi.Tests.System/compose.system.yaml b/tests/WebApi.Tests.System/compose.system.yaml index 4df77c0..9c15fa0 100644 --- a/tests/WebApi.Tests.System/compose.system.yaml +++ b/tests/WebApi.Tests.System/compose.system.yaml @@ -3,9 +3,12 @@ services: build: ../../src/WebApi environment: Vegasco_ConnectionStrings__Default: "Host=db;Port=5432;Database=postgres;Username=postgres;Password=postgres" - Vegasco_JWT__Issuer: - Vegasco_JWT__Authority: - Vegasco_JWT__Audience: + Vegasco_JWT__MetadataUrl: http://login:8080/realms/development/.well-known/openid-configuration + Vegasco_JWT__ValidAudience: vegasco + Vegasco_JWT__NameClaimType: name + Vegasco_JWT__AllowHttpMetadataUrl: "true" + ports: + - "8080" depends_on: db: condition: service_healthy @@ -38,12 +41,12 @@ services: KC_DB_PASSWORD: keycloak KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN_PASSWORD: admin1! - KC_HOSTNAME: http://localhost:12345/ + KC_HOSTNAME_STRICT: false KC_HEALTH_ENABLED: true KC_METRICS_ENABLED: true KC_HTTP_ENABLED: true ports: - - 12345:8080 + - "8080" volumes: - ./test-realm.json:/opt/keycloak/data/import/test-realm.json:ro depends_on: diff --git a/tests/WebApi.Tests.System/test-realm.json b/tests/WebApi.Tests.System/test-realm.json index d654156..46481f5 100644 --- a/tests/WebApi.Tests.System/test-realm.json +++ b/tests/WebApi.Tests.System/test-realm.json @@ -667,6 +667,7 @@ "enabled" : true, "alwaysDisplayInConsole" : false, "clientAuthenticatorType" : "client-secret", + "secret" : "siIgnkijkkIxeQ9BDNwnGGUb60S53QZh", "redirectUris" : [ "*" ], "webOrigins" : [ ], "notBefore" : 0, @@ -674,15 +675,17 @@ "consentRequired" : false, "standardFlowEnabled" : true, "implicitFlowEnabled" : false, - "directAccessGrantsEnabled" : false, + "directAccessGrantsEnabled" : true, "serviceAccountsEnabled" : false, - "publicClient" : true, + "publicClient" : false, "frontchannelLogout" : true, "protocol" : "openid-connect", "attributes" : { "oidc.ciba.grant.enabled" : "false", + "client.secret.creation.time" : "1723219692", "backchannel.logout.session.required" : "true", "post.logout.redirect.uris" : "*", + "display.on.consent.screen" : "false", "oauth2.device.authorization.grant.enabled" : "false", "backchannel.logout.revoke.offline.tokens" : "false" }, @@ -1279,7 +1282,7 @@ "subType" : "authenticated", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "saml-user-property-mapper", "oidc-address-mapper", "oidc-full-name-mapper", "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper", "saml-user-attribute-mapper", "saml-role-list-mapper", "oidc-sha256-pairwise-sub-mapper" ] + "allowed-protocol-mapper-types" : [ "oidc-usermodel-attribute-mapper", "oidc-full-name-mapper", "oidc-address-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-property-mapper", "saml-user-attribute-mapper", "saml-role-list-mapper", "saml-user-property-mapper" ] } }, { "id" : "b099d087-5954-460d-902f-def7799cb005", @@ -1297,7 +1300,7 @@ "subType" : "anonymous", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "oidc-usermodel-attribute-mapper", "saml-user-attribute-mapper", "oidc-usermodel-property-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-full-name-mapper", "oidc-address-mapper", "saml-role-list-mapper", "saml-user-property-mapper" ] + "allowed-protocol-mapper-types" : [ "oidc-sha256-pairwise-sub-mapper", "oidc-address-mapper", "saml-user-property-mapper", "saml-role-list-mapper", "oidc-usermodel-property-mapper", "oidc-full-name-mapper", "oidc-usermodel-attribute-mapper", "saml-user-attribute-mapper" ] } }, { "id" : "e86226d6-0944-4c08-b809-d72e6c7991c4",