From 3952066f57ae112fe04f0331a1595fd25ce6dd65 Mon Sep 17 00:00:00 2001 From: James Hollinger <39168456+james-hollinger@users.noreply.github.com> Date: Tue, 27 Feb 2024 17:16:33 -0800 Subject: [PATCH 1/2] PIDP-842 - Create Keycloak User for BC Provider (#484) * Keycloak create, find + misc * generate username * update logger * make do thing * update check * move * Fix tests --- .../Features/Credentials/BCProvider.Create.cs | 19 ++++- .../PlrCpnLookupFoundHandlers.cs | 2 +- .../Features/Credentials/BCProvider.Create.cs | 67 +++++++++++++-- backend/webapi/Features/Credentials/Create.cs | 2 +- .../Infrastructure/HttpClients/BaseClient.cs | 61 ++++++++++---- .../Keycloak/IKeycloakAdministrationClient.cs | 23 +++-- .../Keycloak/KeycloakAdministrationClient.cs | 84 ++++++++++++++----- .../Keycloak/KeycloakApiDefinitions.cs | 20 ++--- 8 files changed, 208 insertions(+), 70 deletions(-) diff --git a/backend/webapi.tests/Features/Credentials/BCProvider.Create.cs b/backend/webapi.tests/Features/Credentials/BCProvider.Create.cs index c3a171b44..06b27c333 100644 --- a/backend/webapi.tests/Features/Credentials/BCProvider.Create.cs +++ b/backend/webapi.tests/Features/Credentials/BCProvider.Create.cs @@ -9,6 +9,7 @@ namespace PidpTests.Features.Credentials; using Pidp.Infrastructure.Auth; using Pidp.Infrastructure.HttpClients.BCProvider; using Pidp.Infrastructure.HttpClients.Plr; +using Pidp.Infrastructure.HttpClients.Keycloak; using Pidp.Models; using Pidp.Models.Lookups; using PidpTests.TestingExtensions; @@ -21,6 +22,7 @@ public async void CreateBCProvider_VaryingLicenceStatus_ProviderCreatedMatchingF { var expectedPassword = "p4ssw@rD"; var expectedHpdid = "AnHpdid1123123"; + var expectedNewUserId = Guid.NewGuid(); var party = this.TestDb.HasAParty(party => { party.FirstName = "partyfirst"; @@ -48,11 +50,18 @@ public async void CreateBCProvider_VaryingLicenceStatus_ProviderCreatedMatchingF .Returns(new User { UserPrincipalName = "aname" }); var plrClient = A.Fake() .ReturningAStandingsDigest(plrStanding); - var handler = this.MockDependenciesFor(bcProviderClient, plrClient); + UserRepresentation? capturedNewKeycloakUser = null; + var keycloakClient = A.Fake(); + A.CallTo(() => keycloakClient.CreateUser(A._)) + .Invokes(i => capturedNewKeycloakUser = i.GetArgument(0)) + .Returns(expectedNewUserId); + + var handler = this.MockDependenciesFor(bcProviderClient, plrClient, keycloakClient); var result = await handler.HandleAsync(new Command { PartyId = party.Id, Password = expectedPassword }); Assert.True(result.IsSuccess); + Assert.NotNull(capturedNewUser); Assert.Equal(party.Cpn, capturedNewUser.Cpn); Assert.Equal(party.FirstName, capturedNewUser.FirstName); @@ -63,6 +72,14 @@ public async void CreateBCProvider_VaryingLicenceStatus_ProviderCreatedMatchingF Assert.Equal(expectedRnp, capturedNewUser.IsRnp); Assert.Equal(party.Email, capturedNewUser.PidpEmail); Assert.Equal(expectedPassword, capturedNewUser.Password); + + Assert.NotNull(capturedNewKeycloakUser); + Assert.True(capturedNewKeycloakUser.Enabled); + Assert.Equal(party.FirstName, capturedNewKeycloakUser.FirstName); + Assert.Equal(party.LastName, capturedNewKeycloakUser.LastName); + + Assert.Single(party.Credentials.Where(credential => credential.IdentityProvider == IdentityProviders.BCProvider)); + Assert.Equal(expectedNewUserId, party.Credentials.Single(credential => credential.IdentityProvider == IdentityProviders.BCProvider).UserId); } public static IEnumerable LicenceTestCases() diff --git a/backend/webapi/Features/CommonHandlers/PlrCpnLookupFoundHandlers.cs b/backend/webapi/Features/CommonHandlers/PlrCpnLookupFoundHandlers.cs index c21dbe27e..3fadb9dda 100644 --- a/backend/webapi/Features/CommonHandlers/PlrCpnLookupFoundHandlers.cs +++ b/backend/webapi/Features/CommonHandlers/PlrCpnLookupFoundHandlers.cs @@ -23,7 +23,7 @@ public async Task Handle(PlrCpnLookupFound notification, CancellationToken cance // TODO: what to do if this fails? foreach (var userId in notification.UserIds) { - await this.client.UpdateUserCpn(userId, notification.Cpn); + await this.client.UpdateUser(userId, (user) => user.SetCpn(notification.Cpn)); } } } diff --git a/backend/webapi/Features/Credentials/BCProvider.Create.cs b/backend/webapi/Features/Credentials/BCProvider.Create.cs index a72434db4..73214071e 100644 --- a/backend/webapi/Features/Credentials/BCProvider.Create.cs +++ b/backend/webapi/Features/Credentials/BCProvider.Create.cs @@ -2,6 +2,7 @@ namespace Pidp.Features.Credentials; using DomainResults.Common; using FluentValidation; +using Flurl; using HybridModelBinding; using Microsoft.EntityFrameworkCore; using System.Text.Json.Serialization; @@ -10,6 +11,7 @@ namespace Pidp.Features.Credentials; using Pidp.Extensions; using Pidp.Infrastructure.Auth; using Pidp.Infrastructure.HttpClients.BCProvider; +using Pidp.Infrastructure.HttpClients.Keycloak; using Pidp.Infrastructure.HttpClients.Mail; using Pidp.Infrastructure.HttpClients.Plr; using Pidp.Models; @@ -39,22 +41,28 @@ public class CommandHandler : ICommandHandler> { private readonly IBCProviderClient client; private readonly IEmailService emailService; - private readonly PidpDbContext context; - private readonly ILogger logger; + private readonly IKeycloakAdministrationClient keycloakClient; + private readonly ILogger logger; private readonly IPlrClient plrClient; + private readonly PidpDbContext context; + private readonly Url keycloakAdministrationUrl; public CommandHandler( IBCProviderClient client, IEmailService emailService, - PidpDbContext context, + IKeycloakAdministrationClient keycloakClient, ILogger logger, - IPlrClient plrClient) + IPlrClient plrClient, + PidpConfiguration config, + PidpDbContext context) { this.client = client; - this.context = context; this.emailService = emailService; + this.keycloakClient = keycloakClient; this.logger = logger; this.plrClient = plrClient; + this.context = context; + this.keycloakAdministrationUrl = Url.Parse(config.Keycloak.AdministrationUrl); } public async Task> HandleAsync(Command command) @@ -122,17 +130,55 @@ public async Task> HandleAsync(Command command) return DomainResult.Failed(); } + // TODO: Domain Event! Probably should create this credential now and then Queue the keycloak User creation + updating the UserId + var userId = await this.CreateKeycloakUser(party.FirstName, party.LastName, createdUser.UserPrincipalName); + if (userId == null) + { + return DomainResult.Failed(); + } + this.context.Credentials.Add(new Credential { + UserId = userId.Value, PartyId = command.PartyId, IdpId = createdUser.UserPrincipalName, IdentityProvider = IdentityProviders.BCProvider }); await this.context.SaveChangesAsync(); - await this.SendBCProviderCreationEmail(newUserRep.PidpEmail, createdUser.UserPrincipalName); + await this.SendBCProviderCreationEmail(party.Email, createdUser.UserPrincipalName); + + return DomainResult.Success(createdUser.UserPrincipalName); + } - return DomainResult.Success(createdUser.UserPrincipalName!); + private async Task CreateKeycloakUser(string firstName, string lastName, string userPrincipalName) + { + var newUser = new UserRepresentation + { + Enabled = true, + FirstName = firstName, + LastName = lastName, + Username = this.GenerateMohKeycloakUsername(userPrincipalName) + }; + return await this.keycloakClient.CreateUser(newUser); + } + + /// + /// The expected Ministry of Health Keycloak username for this user. The schema is {IdentityProviderIdentifier}@{IdentityProvider}. + /// Most of our Credentials come to us from Keycloak and so the username is saved as-is in the column IdpId. + /// However, we create BC Provider Credentials directly; so the User Principal Name is saved to the database without the suffix. + /// There are also two inconsistencies with how the MOH Keycloak handles BCP usernames: + /// 1. The username suffix is @bcp rather than @bcprovider_aad. + /// 2. This suffix is only added in Test and Production; there is no suffix at all for BCP Users in the Dev Keycloak. + /// + private string GenerateMohKeycloakUsername(string userPrincipalName) + { + if (this.keycloakAdministrationUrl.Host == "user-management-dev.api.hlth.gov.bc.ca") + { + return userPrincipalName; + } + + return userPrincipalName + "@bcp"; } private async Task SendBCProviderCreationEmail(string partyEmail, string userPrincipalName) @@ -152,8 +198,11 @@ private async Task SendBCProviderCreationEmail(string partyEmail, string userPri public static partial class BCProviderCreateLoggingExtensions { [LoggerMessage(1, LogLevel.Information, "Party {partyId} attempted to create a second BC Provider account.")] - public static partial void LogPartyHasBCProviderCredential(this ILogger logger, int partyId); + public static partial void LogPartyHasBCProviderCredential(this ILogger logger, int partyId); [LoggerMessage(2, LogLevel.Error, "Failed to create BC Provider for Party {partyId}, one or more requirements were not met. Party state: {state}.")] - public static partial void LogInvalidState(this ILogger logger, int partyId, object state); + public static partial void LogInvalidState(this ILogger logger, int partyId, object state); + + [LoggerMessage(3, LogLevel.Error, "Error when attempting to create a Keycloak User for Party {partyId} with username {username}.")] + public static partial void LogKeycloakUserCreationError(this ILogger logger, int partyId, string username); } diff --git a/backend/webapi/Features/Credentials/Create.cs b/backend/webapi/Features/Credentials/Create.cs index b5c1d3dce..14be096a0 100644 --- a/backend/webapi/Features/Credentials/Create.cs +++ b/backend/webapi/Features/Credentials/Create.cs @@ -200,5 +200,5 @@ public static partial class CredentialCreateLoggingExtensions public static partial void LogCredentialLinkTicketExpired(this ILogger logger, int credentialLinkTicketId); [LoggerMessage(4, LogLevel.Error, "Credential Link Ticket {credentialLinkTicketId} expected to link to IDP {expectedIdp}, user had IDP {actualIdp} instead.")] - public static partial void LogCredentialLinkTicketIdpError(this ILogger logger, int credentialLinkTicketId, string expectedIdp, string? actualIdp); + public static partial void LogCredentialLinkTicketIdpError(this ILogger logger, int credentialLinkTicketId, string expectedIdp, string actualIdp); } diff --git a/backend/webapi/Infrastructure/HttpClients/BaseClient.cs b/backend/webapi/Infrastructure/HttpClients/BaseClient.cs index f3cd7a29d..ec9127024 100644 --- a/backend/webapi/Infrastructure/HttpClients/BaseClient.cs +++ b/backend/webapi/Infrastructure/HttpClients/BaseClient.cs @@ -3,6 +3,7 @@ namespace Pidp.Infrastructure.HttpClients; using DomainResults.Common; using Flurl; using System.Net; +using System.Net.Http.Headers; using System.Text; using System.Text.Json; @@ -73,6 +74,18 @@ public BaseClient(HttpClient client, ILogger logger, PropertySerialization optio /// protected async Task> PostAsync(string url, object? data = null) => await this.SendCoreAsync(HttpMethod.Post, url, data == null ? null : this.CreateStringContent(data), default); + /// + /// Performs a POST to the supplied Url with an optional JSON StringContent body as per the serialization settings set in the constructor. + /// Produces a DomainResult and the value of the Location response header. + /// + /// + /// + protected async Task<(IDomainResult Status, Uri? Location)> PostWithLocationAsync(string url, object? data = null) + { + var (status, headers) = await this.SendCoreWithHeadersAsync(HttpMethod.Post, url, data == null ? null : this.CreateStringContent(data), default); + return (status, headers?.Location); + } + /// /// Performs a PUT to the supplied Url with an optional JSON StringContent body as per the serialization settings set in the constructor /// @@ -91,13 +104,13 @@ public BaseClient(HttpClient client, ILogger logger, PropertySerialization optio /// protected async Task SendCoreAsync(HttpMethod method, string url, HttpContent? content, CancellationToken cancellationToken) { - var result = await this.SendCoreInternalAsync(method, url, content, true, cancellationToken); - result.Deconstruct(out _, out var details); + var (status, _) = await this.SendCoreInternalAsync(method, url, content, true, cancellationToken); + status.Deconstruct(out _, out var details); return details; } /// - /// Send an HTTTP message to the API; returning: + /// Send an HTTP message to the API; returning: /// a) a Success result with a (non-null) value of the indicated type, or /// b) a Failure result in the case of errors, non-success status codes, or a missing/null response value. /// @@ -106,9 +119,25 @@ protected async Task SendCoreAsync(HttpMethod method, string url, /// /// /// - protected async Task> SendCoreAsync(HttpMethod method, string url, HttpContent? content, CancellationToken cancellationToken) => await this.SendCoreInternalAsync(method, url, content, false, cancellationToken); + protected async Task> SendCoreAsync(HttpMethod method, string url, HttpContent? content, CancellationToken cancellationToken) => (await this.SendCoreInternalAsync(method, url, content, false, cancellationToken)).Status; + + /// + /// Sends an HTTP message to the API; returning the response headers as well as: + /// a) a Success result, or + /// b) a Failure result in the case of errors or a non-success status code + /// + /// + /// + /// + /// + protected async Task<(IDomainResult Status, HttpResponseHeaders? Headers)> SendCoreWithHeadersAsync(HttpMethod method, string url, HttpContent? content, CancellationToken cancellationToken) + { + var (status, headers) = await this.SendCoreInternalAsync(method, url, content, true, cancellationToken); + status.Deconstruct(out _, out var details); + return (details, headers); + } - private async Task> SendCoreInternalAsync(HttpMethod method, string url, HttpContent? content, bool ignoreResponseContent, CancellationToken cancellationToken) + private async Task<(IDomainResult Status, HttpResponseHeaders? Headers)> SendCoreInternalAsync(HttpMethod method, string url, HttpContent? content, bool ignoreResponseContent, CancellationToken cancellationToken) { try { @@ -131,55 +160,55 @@ private async Task> SendCoreInternalAsync(HttpMethod method, : ""; this.Logger.LogNonSuccessStatusCode(response.StatusCode, responseMessage); - return DomainResult.Failed(response.StatusCode == HttpStatusCode.NotFound + return (DomainResult.Failed(response.StatusCode == HttpStatusCode.NotFound ? $"The URL {url} was not found" - : "Did not receive a successful status code"); + : "Did not receive a successful status code"), response.Headers); } if (ignoreResponseContent) { - return DomainResult.Success(default!); + return (DomainResult.Success(default!), response.Headers); } if (response.Content == null) { this.Logger.LogNullResponseContent(); - return DomainResult.Failed("Response content was null"); + return (DomainResult.Failed("Response content was null"), response.Headers); } var deserializationResult = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); if (deserializationResult == null) { this.Logger.LogNullResponseContent(); - return DomainResult.Failed("Response content was null"); + return (DomainResult.Failed("Response content was null"), response.Headers); } - return DomainResult.Success(deserializationResult); + return (DomainResult.Success(deserializationResult), response.Headers); } catch (HttpRequestException exception) { this.Logger.LogBaseClientException(exception); - return DomainResult.Failed("HttpRequestException during call to API"); + return (DomainResult.Failed("HttpRequestException during call to API"), null); } catch (TimeoutException exception) { this.Logger.LogBaseClientException(exception); - return DomainResult.Failed("TimeoutException during call to API"); + return (DomainResult.Failed("TimeoutException during call to API"), null); } catch (OperationCanceledException exception) { this.Logger.LogBaseClientException(exception); - return DomainResult.Failed("Task was canceled during call to API"); + return (DomainResult.Failed("Task was canceled during call to API"), null); } catch (JsonException exception) { this.Logger.LogBaseClientException(exception); - return DomainResult.Failed("Could not deserialize API response"); + return (DomainResult.Failed("Could not deserialize API response"), null); } catch (Exception exception) { this.Logger.LogBaseClientException(exception); - return DomainResult.Failed("Unhandled exception when calling the API"); + return (DomainResult.Failed("Unhandled exception when calling the API"), null); } } } diff --git a/backend/webapi/Infrastructure/HttpClients/Keycloak/IKeycloakAdministrationClient.cs b/backend/webapi/Infrastructure/HttpClients/Keycloak/IKeycloakAdministrationClient.cs index 47dc0c6a7..b79141648 100644 --- a/backend/webapi/Infrastructure/HttpClients/Keycloak/IKeycloakAdministrationClient.cs +++ b/backend/webapi/Infrastructure/HttpClients/Keycloak/IKeycloakAdministrationClient.cs @@ -27,6 +27,20 @@ public interface IKeycloakAdministrationClient /// Task AssignRealmRole(Guid userId, string roleName); + /// + /// Creates a new User in Keycloak. Username must be unique. + /// Returns the User's UserId if successful. + /// + /// + Task CreateUser(UserRepresentation userRep); + + /// + /// Finds a User by Username. + /// Returns null on a failure or if no User is found. + /// + /// + Task FindUser(string username); + /// /// Gets the Keycloak Client representation by ClientId. /// Returns null if unsuccessful. @@ -86,13 +100,4 @@ public interface IKeycloakAdministrationClient /// /// Task UpdateUser(Guid userId, Action updateAction); - - /// - /// Fetches the User and updates their CPN with the provided value. Does not update if the provided cpn is null. - /// A convenience method for UpdateUser. - /// Returns true if the operation was successful. - /// - /// - /// - Task UpdateUserCpn(Guid userId, string? cpn); } diff --git a/backend/webapi/Infrastructure/HttpClients/Keycloak/KeycloakAdministrationClient.cs b/backend/webapi/Infrastructure/HttpClients/Keycloak/KeycloakAdministrationClient.cs index db30cf54f..773460670 100644 --- a/backend/webapi/Infrastructure/HttpClients/Keycloak/KeycloakAdministrationClient.cs +++ b/backend/webapi/Infrastructure/HttpClients/Keycloak/KeycloakAdministrationClient.cs @@ -66,13 +66,60 @@ public async Task AssignRealmRole(Guid userId, string roleName) } // Keycloak expects an array of roles. - var response = await this.PostAsync($"users/{userId}/role-mappings/realm", new[] { role }); - if (response.IsSuccess) + var result = await this.PostAsync($"users/{userId}/role-mappings/realm", new[] { role }); + if (result.IsSuccess) { this.Logger.LogRealmRoleAssigned(userId, roleName); } - return response.IsSuccess; + return result.IsSuccess; + } + + public async Task CreateUser(UserRepresentation userRep) + { + if (userRep.Username == null) + { + return null; + } + + var (status, location) = await this.PostWithLocationAsync("users", userRep); + if (!status.IsSuccess) + { + this.Logger.LogUserCreationError(userRep); + return null; + } + + if (location != null) + { + var id = location.Segments.Last(); + if (Guid.TryParse(id, out var userId)) + { + return userId; + } + } + + this.Logger.LogUserCreationLocationError(userRep.Username, location); + + var user = await this.FindUser(userRep.Username); + if (Guid.TryParse(user?.Id, out var result)) + { + return result; + } + + this.Logger.LogCreatedUserNotFound(); + return null; + } + + public async Task FindUser(string username) + { + var result = await this.GetWithQueryParamsAsync>("users", new { Username = username }); + if (!result.IsSuccess || result.Value.Count > 1) + { + this.Logger.LogFindUserError(username); + return null; + } + + return result.Value.SingleOrDefault(); } public async Task GetClient(string clientId) @@ -84,7 +131,7 @@ public async Task AssignRealmRole(Guid userId, string roleName) return null; } - var client = result.Value?.SingleOrDefault(c => c.ClientId == clientId); + var client = result.Value.SingleOrDefault(c => c.ClientId == clientId); if (client == null) { @@ -185,22 +232,6 @@ public async Task UpdateUser(Guid userId, Action updat return await this.UpdateUser(userId, user); } - - public async Task UpdateUserCpn(Guid userId, string? cpn) - { - if (cpn == null) - { - return true; - } - - var result = await this.UpdateUser(userId, (user) => user.SetCpn(cpn)); - if (!result) - { - this.Logger.LogCpnUpdateFailure(userId, cpn); - } - - return result; - } } public static partial class KeycloakAdministrationClientLoggingExtensions @@ -217,6 +248,15 @@ public static partial class KeycloakAdministrationClientLoggingExtensions [LoggerMessage(4, LogLevel.Information, "User {userId} was assigned Realm Role {roleName}.")] public static partial void LogRealmRoleAssigned(this ILogger logger, Guid userId, string roleName); - [LoggerMessage(5, LogLevel.Error, "Failed to update user {userId} with CPN {cpn}.")] - public static partial void LogCpnUpdateFailure(this ILogger logger, Guid userId, string cpn); + [LoggerMessage(5, LogLevel.Error, "Error when creating a User with the representation: {userRep}.")] + public static partial void LogUserCreationError(this ILogger logger, UserRepresentation userRep); + + [LoggerMessage(6, LogLevel.Error, "Error when creating a User. Keycloak returned a success code but had a missing/malformed Location header. Username: {username}, Location header: {locationHeader}.")] + public static partial void LogUserCreationLocationError(this ILogger logger, string username, Uri? locationHeader); + + [LoggerMessage(7, LogLevel.Error, "Keycloak returned a success code but the user could not be found by either Location header or by searching for Username.")] + public static partial void LogCreatedUserNotFound(this ILogger logger); + + [LoggerMessage(8, LogLevel.Error, "Error when finding user with username {username}.")] + public static partial void LogFindUserError(this ILogger logger, string username); } diff --git a/backend/webapi/Infrastructure/HttpClients/Keycloak/KeycloakApiDefinitions.cs b/backend/webapi/Infrastructure/HttpClients/Keycloak/KeycloakApiDefinitions.cs index 116431f3d..435a1e665 100644 --- a/backend/webapi/Infrastructure/HttpClients/Keycloak/KeycloakApiDefinitions.cs +++ b/backend/webapi/Infrastructure/HttpClients/Keycloak/KeycloakApiDefinitions.cs @@ -38,7 +38,7 @@ private MohKeycloakEnrolment(string clientId, AccessTypeCode? associatedAccessRe } /// -/// This is not the entire Keycloak Client Representation! See https://www.keycloak.org/docs-api/5.0/rest-api/index.html#_clientrepresentation. +/// This is not the entire Keycloak Client Representation! See https://www.keycloak.org/docs-api/22.0.1/rest-api/index.html#ClientRepresentation. /// public class Client { @@ -69,13 +69,17 @@ public class Role } /// -/// This is not the entire Keycloak User Representation! See https://www.keycloak.org/docs-api/18.0/rest-api/index.html#_userrepresentation. -/// This is a sub-set of the properties so we don't accidentally overwrite anything when doing the PUT. +/// This is not the entire Keycloak User Representation! See https://www.keycloak.org/docs-api/22.0.1/rest-api/index.html#UserRepresentation. /// public class UserRepresentation { - public string? Email { get; set; } public Dictionary Attributes { get; set; } = new(); + public string? Email { get; set; } + public bool? Enabled { get; set; } + public string? FirstName { get; set; } + public string? Id { get; set; } + public string? LastName { get; set; } + public string? Username { get; set; } internal void SetLdapOrgDetails(LdapLoginResponse.OrgDetails orgDetails) => this.SetAttribute("org_details", JsonSerializer.Serialize(orgDetails, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); @@ -83,14 +87,8 @@ public class UserRepresentation public void SetPidpEmail(string pidpEmail) => this.SetAttribute("pidp_email", pidpEmail); - public void SetPhone(string phone) => this.SetAttribute("phone", phone); - - public void SetPhoneNumber(string phoneNumber) => this.SetAttribute("phoneNumber", phoneNumber); - - public void SetPhoneExtension(string phoneExtension) => this.SetAttribute("phoneExtension", phoneExtension); - /// - /// Adds the given attributes to this User Representation. Overrites any duplicate keys. + /// Adds the given attributes to this User Representation. Overwrites any duplicate keys. /// public void SetAttributes(Dictionary newAttributes) { From 45fec32693e3e7982b0d9cffc62396848db1d736 Mon Sep 17 00:00:00 2001 From: James Hollinger <39168456+james-hollinger@users.noreply.github.com> Date: Wed, 28 Feb 2024 14:14:42 -0800 Subject: [PATCH 2/2] Fix Keycloak FindUser (#486) --- .../HttpClients/Keycloak/KeycloakAdministrationClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/webapi/Infrastructure/HttpClients/Keycloak/KeycloakAdministrationClient.cs b/backend/webapi/Infrastructure/HttpClients/Keycloak/KeycloakAdministrationClient.cs index 773460670..d40376ecd 100644 --- a/backend/webapi/Infrastructure/HttpClients/Keycloak/KeycloakAdministrationClient.cs +++ b/backend/webapi/Infrastructure/HttpClients/Keycloak/KeycloakAdministrationClient.cs @@ -112,7 +112,7 @@ public async Task AssignRealmRole(Guid userId, string roleName) public async Task FindUser(string username) { - var result = await this.GetWithQueryParamsAsync>("users", new { Username = username }); + var result = await this.GetWithQueryParamsAsync>("users", new { username }); if (!result.IsSuccess || result.Value.Count > 1) { this.Logger.LogFindUserError(username);