diff --git a/NETCore.Keycloak.Client/HttpClients/Abstraction/IKcOrganizations.cs b/NETCore.Keycloak.Client/HttpClients/Abstraction/IKcOrganizations.cs new file mode 100644 index 0000000..99c0a23 --- /dev/null +++ b/NETCore.Keycloak.Client/HttpClients/Abstraction/IKcOrganizations.cs @@ -0,0 +1,128 @@ +using NETCore.Keycloak.Client.Exceptions; +using NETCore.Keycloak.Client.Models; +using NETCore.Keycloak.Client.Models.Organizations; + +namespace NETCore.Keycloak.Client.HttpClients.Abstraction; + +/// +/// Keycloak organizations REST client. +/// +/// +public interface IKcOrganizations +{ + /// + /// Creates a new organization in a specified Keycloak realm. + /// + /// POST /{realm}/organizations + /// + /// The Keycloak realm where the organization will be created. + /// The access token used for authentication. + /// The organization representation to create. + /// Optional cancellation token. + /// + /// A indicating the result of the operation. + /// + /// Thrown if any required parameter is null, empty, or invalid. + Task> CreateAsync( + string realm, + string accessToken, + KcOrganization organization, + CancellationToken cancellationToken = default); + + /// + /// Updates an existing organization in a specified Keycloak realm. + /// + /// PUT /{realm}/organizations/{organizationId} + /// + /// The Keycloak realm where the organization exists. + /// The access token used for authentication. + /// The ID of the organization to update. + /// The updated organization representation. + /// Optional cancellation token. + /// + /// A indicating the result of the operation. + /// + /// Thrown if any required parameter is null, empty, or invalid. + Task> UpdateAsync( + string realm, + string accessToken, + string organizationId, + KcOrganization organization, + CancellationToken cancellationToken = default); + + /// + /// Deletes an organization from a specified Keycloak realm. + /// + /// DELETE /{realm}/organizations/{organizationId} + /// + /// The Keycloak realm where the organization exists. + /// The access token used for authentication. + /// The ID of the organization to delete. + /// Optional cancellation token. + /// + /// A indicating the result of the operation. + /// + /// Thrown if any required parameter is null, empty, or invalid. + Task> DeleteAsync( + string realm, + string accessToken, + string organizationId, + CancellationToken cancellationToken = default); + + /// + /// Retrieves a specific organization by its ID from a specified Keycloak realm. + /// + /// GET /{realm}/organizations/{organizationId} + /// + /// The Keycloak realm to query. + /// The access token used for authentication. + /// The ID of the organization to retrieve. + /// Optional cancellation token. + /// + /// A containing the details. + /// + /// Thrown if any required parameter is null, empty, or invalid. + Task> GetAsync( + string realm, + string accessToken, + string organizationId, + CancellationToken cancellationToken = default); + + /// + /// Retrieves a list of organizations from a specified Keycloak realm, optionally filtered by criteria. + /// + /// GET /{realm}/organizations + /// + /// The Keycloak realm from which organizations will be listed. + /// The access token used for authentication. + /// Optional filter criteria. + /// Optional cancellation token. + /// + /// A containing an enumerable of objects. + /// + /// Thrown if any required parameter is null, empty, or invalid. + Task>> ListAsync( + string realm, + string accessToken, + KcOrganizationFilter filter = null, + CancellationToken cancellationToken = default); + + /// + /// Retrieves the count of organizations in a specified Keycloak realm, optionally filtered. + /// + /// GET /{realm}/organizations/count + /// + /// The Keycloak realm to query. + /// The access token used for authentication. + /// Optional filter criteria. + /// Optional cancellation token. + /// + /// A with the count of organizations. + /// + /// Thrown if any required parameter is null, empty, or invalid. + Task> CountAsync( + string realm, + string accessToken, + KcOrganizationFilter filter = null, + CancellationToken cancellationToken = default); +} diff --git a/NETCore.Keycloak.Client/HttpClients/Abstraction/IKeycloakClient.cs b/NETCore.Keycloak.Client/HttpClients/Abstraction/IKeycloakClient.cs index 70409ea..d5edde5 100644 --- a/NETCore.Keycloak.Client/HttpClients/Abstraction/IKeycloakClient.cs +++ b/NETCore.Keycloak.Client/HttpClients/Abstraction/IKeycloakClient.cs @@ -76,4 +76,9 @@ public interface IKeycloakClient /// See for detailed operations. /// public IKcScopeMappings ScopeMappings { get; } + + /// + /// Gets the organizations REST client for managing organizations. + /// + public IKcOrganizations Organizations { get; } } diff --git a/NETCore.Keycloak.Client/HttpClients/Implementation/KcOrganizations.cs b/NETCore.Keycloak.Client/HttpClients/Implementation/KcOrganizations.cs new file mode 100644 index 0000000..3bc7098 --- /dev/null +++ b/NETCore.Keycloak.Client/HttpClients/Implementation/KcOrganizations.cs @@ -0,0 +1,141 @@ +using Microsoft.Extensions.Logging; +using NETCore.Keycloak.Client.HttpClients.Abstraction; +using NETCore.Keycloak.Client.Models; +using NETCore.Keycloak.Client.Models.Organizations; + +namespace NETCore.Keycloak.Client.HttpClients.Implementation; + +/// +internal sealed class KcOrganizations(string baseUrl, + ILogger logger) : KcHttpClientBase(logger, baseUrl), IKcOrganizations +{ + // Primary constructor on the class declaration is used; no explicit ctor body required. + + /// + public Task> CreateAsync( + string realm, + string accessToken, + KcOrganization organization, + CancellationToken cancellationToken = default) + { + ValidateAccess(realm, accessToken); + ValidateNotNull(nameof(organization), organization); + + var url = $"{BaseUrl}/{realm}/organizations"; + return ProcessRequestAsync( + url, + HttpMethod.Post, + accessToken, + "Unable to create organization", + organization, + "application/json", + cancellationToken); + } + + /// + public Task> UpdateAsync( + string realm, + string accessToken, + string organizationId, + KcOrganization organization, + CancellationToken cancellationToken = default) + { + ValidateAccess(realm, accessToken); + ValidateRequiredString(nameof(organizationId), organizationId); + ValidateNotNull(nameof(organization), organization); + + var url = $"{BaseUrl}/{realm}/organizations/{organizationId}"; + return ProcessRequestAsync( + url, + HttpMethod.Put, + accessToken, + "Unable to update organization", + organization, + "application/json", + cancellationToken); + } + + /// + public Task> DeleteAsync( + string realm, + string accessToken, + string organizationId, + CancellationToken cancellationToken = default) + { + ValidateAccess(realm, accessToken); + ValidateRequiredString(nameof(organizationId), organizationId); + + var url = $"{BaseUrl}/{realm}/organizations/{organizationId}"; + return ProcessRequestAsync( + url, + HttpMethod.Delete, + accessToken, + "Unable to delete organization", + null, + "application/json", + cancellationToken); + } + + /// + public Task> GetAsync( + string realm, + string accessToken, + string organizationId, + CancellationToken cancellationToken = default) + { + ValidateAccess(realm, accessToken); + ValidateRequiredString(nameof(organizationId), organizationId); + + var url = $"{BaseUrl}/{realm}/organizations/{organizationId}"; + return ProcessRequestAsync( + url, + HttpMethod.Get, + accessToken, + "Unable to get organization", + null, + "application/json", + cancellationToken); + } + + /// + public Task>> ListAsync( + string realm, + string accessToken, + KcOrganizationFilter filter = null, + CancellationToken cancellationToken = default) + { + ValidateAccess(realm, accessToken); + filter ??= new KcOrganizationFilter(); + + var url = $"{BaseUrl}/{realm}/organizations{filter.BuildQuery()}"; + return ProcessRequestAsync>( + url, + HttpMethod.Get, + accessToken, + "Unable to list organizations", + null, + "application/json", + cancellationToken); + } + + /// + public Task> CountAsync( + string realm, + string accessToken, + KcOrganizationFilter filter = null, + CancellationToken cancellationToken = default) + { + ValidateAccess(realm, accessToken); + filter ??= new KcOrganizationFilter(); + + var url = $"{BaseUrl}/{realm}/organizations/count{filter.BuildQuery()}"; + return ProcessRequestAsync( + url, + HttpMethod.Get, + accessToken, + "Unable to count organizations", + null, + "application/json", + cancellationToken); + } +} diff --git a/NETCore.Keycloak.Client/HttpClients/Implementation/KeycloakClient.cs b/NETCore.Keycloak.Client/HttpClients/Implementation/KeycloakClient.cs index a62d077..9ced037 100644 --- a/NETCore.Keycloak.Client/HttpClients/Implementation/KeycloakClient.cs +++ b/NETCore.Keycloak.Client/HttpClients/Implementation/KeycloakClient.cs @@ -44,6 +44,9 @@ public sealed class KeycloakClient : IKeycloakClient /// public IKcScopeMappings ScopeMappings { get; } + /// + public IKcOrganizations Organizations { get; } + /// /// Initializes a new instance of the class. /// Provides access to various Keycloak API services through respective clients. @@ -86,5 +89,6 @@ public KeycloakClient(string baseUrl, ILogger logger = null) ProtocolMappers = new KcProtocolMappers(adminUrl, logger); ScopeMappings = new KcScopeMappings(adminUrl, logger); RoleMappings = new KcRoleMappings(adminUrl, logger); + Organizations = new KcOrganizations(adminUrl, logger); } } diff --git a/NETCore.Keycloak.Client/Models/Organizations/KcOrganization.cs b/NETCore.Keycloak.Client/Models/Organizations/KcOrganization.cs new file mode 100644 index 0000000..339dd82 --- /dev/null +++ b/NETCore.Keycloak.Client/Models/Organizations/KcOrganization.cs @@ -0,0 +1,83 @@ +using System.Text.Json.Serialization; + +namespace NETCore.Keycloak.Client.Models.Organizations; + +/// +/// Represents an organization resource in Keycloak. +/// +/// +public sealed class KcOrganization +{ + /// + /// Gets or sets the organization id. + /// + /// + /// A string representing the unique identifier of the organization. + /// + [JsonPropertyName("id")] + public string Id { get; set; } + + /// + /// Gets or sets the organization name. + /// + /// + /// A string representing the display name of the organization. + /// + [JsonPropertyName("name")] + public string Name { get; set; } + + /// + /// Gets or sets the organization alias. + /// + /// + /// A string representing an alternate identifier or alias for the organization. + /// + [JsonPropertyName("alias")] + public string Alias { get; set; } + + /// + /// Gets or sets a value indicating whether the organization is enabled. + /// + /// + /// A nullable boolean that is true when the organization is enabled, false when disabled, + /// or null if the enabled state is not set. + /// + [JsonPropertyName("enabled")] + public bool? Enabled { get; set; } + + /// + /// Gets or sets the organization description. + /// + /// + /// A string containing a human-readable description for the organization. + /// + [JsonPropertyName("description")] + public string Description { get; set; } + + /// + /// Gets or sets the redirect URL for the organization. + /// + /// + /// A string representing the redirect URL associated with the organization (for example, after login or registration flows). + /// + [JsonPropertyName("redirectUrl")] + public string RedirectUrl { get; set; } + + /// + /// Custom attributes associated with the organization. + /// + /// + /// A dictionary where the key is the attribute name and the value is a list of values for that attribute (Keycloak style). + /// + [JsonPropertyName("attributes")] + public Dictionary> Attributes { get; set; } + + /// + /// Gets or sets the organization domains. + /// + /// + /// A collection of representing domains associated with the organization. + /// + [JsonPropertyName("domains")] + public ICollection Domains { get; set; } = []; +} diff --git a/NETCore.Keycloak.Client/Models/Organizations/KcOrganizationDomain.cs b/NETCore.Keycloak.Client/Models/Organizations/KcOrganizationDomain.cs new file mode 100644 index 0000000..f4424e6 --- /dev/null +++ b/NETCore.Keycloak.Client/Models/Organizations/KcOrganizationDomain.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace NETCore.Keycloak.Client.Models.Organizations; + +/// +/// Represents an organization domain in Keycloak. +/// +/// +public sealed class KcOrganizationDomain +{ + /// + /// Gets or sets the domain name. + /// + /// + /// A string representing the domain name (for example, "example.com"). + /// + [JsonPropertyName("name")] + public string Name { get; set; } + + /// + /// Gets or sets a value indicating whether the domain is verified. + /// + /// + /// A nullable boolean indicating whether the domain has been verified by Keycloak. + /// True if verified; false if not; null if the verification state is unknown. + /// + [JsonPropertyName("verified")] + public bool? Verified { get; set; } +} diff --git a/NETCore.Keycloak.Client/Models/Organizations/KcOrganizationFilter.cs b/NETCore.Keycloak.Client/Models/Organizations/KcOrganizationFilter.cs new file mode 100644 index 0000000..81910a1 --- /dev/null +++ b/NETCore.Keycloak.Client/Models/Organizations/KcOrganizationFilter.cs @@ -0,0 +1,76 @@ +using System.Globalization; +using System.Text; +using NETCore.Keycloak.Client.Models.Common; +using Newtonsoft.Json; + +namespace NETCore.Keycloak.Client.Models.Organizations; + +/// +/// Represents a filter for querying Keycloak organizations. +/// +public sealed class KcOrganizationFilter : KcFilter +{ + /// + /// Gets or sets a query string for searching custom attributes, formatted as 'key1:value1 key2:value2'. + /// + /// + /// A string representing the custom attribute search query. + /// + [JsonProperty("q")] + public string Q { get; set; } + + /// + /// Gets or sets a value indicating whether the query parameters must match exactly. + /// + /// + /// true if the parameters must match exactly; otherwise, false. + /// + [JsonProperty("exact")] + public bool? Exact { get; set; } + + /// + /// Builds the query string based on the filter properties. + /// + /// + /// A string containing the query parameters to be appended to a URL. + /// + public new string BuildQuery() + { + var builder = new StringBuilder($"?max={Max}"); + + // Include brief representation if specified + if ( BriefRepresentation != null ) + { + _ = builder.Append(CultureInfo.CurrentCulture, + $"&briefRepresentation={BriefRepresentation.ToString().ToLower(CultureInfo.CurrentCulture)}"); + } + + // Include pagination offset if specified + if ( First != null ) + { + _ = builder.Append(CultureInfo.CurrentCulture, + $"&first={string.Create(CultureInfo.CurrentCulture, $"{First}").ToLower(CultureInfo.CurrentCulture)}"); + } + + // Include custom attribute query if specified + if ( !string.IsNullOrWhiteSpace(Q) ) + { + _ = builder.Append(CultureInfo.CurrentCulture, $"&q={Q}"); + } + + // Include general search query if specified + if ( !string.IsNullOrWhiteSpace(Search) ) + { + _ = builder.Append(CultureInfo.CurrentCulture, $"&search={Search}"); + } + + // Include exact match filter if specified + if ( Exact != null ) + { + _ = builder.Append(CultureInfo.CurrentCulture, + $"&exact={Exact.ToString().ToLower(CultureInfo.CurrentCulture)}"); + } + + return builder.ToString(); + } +} diff --git a/NETCore.Keycloak.Client/NETCore.Keycloak.Client.csproj b/NETCore.Keycloak.Client/NETCore.Keycloak.Client.csproj index 2cbe2e8..bdd5ec3 100644 --- a/NETCore.Keycloak.Client/NETCore.Keycloak.Client.csproj +++ b/NETCore.Keycloak.Client/NETCore.Keycloak.Client.csproj @@ -28,17 +28,16 @@ - - - - - - - - - - - - + + + + + + + + + + +