diff --git a/.fossa.yml b/.fossa.yml new file mode 100644 index 0000000..6387980 --- /dev/null +++ b/.fossa.yml @@ -0,0 +1,19 @@ +version: 1 + +analysis: + # Paths to exclude from license/security analysis (relative to repo root) + ignorePaths: + - "NETCore.Keycloak.Client.Tests/**" + + # Specific package locators to ignore (use exact locator shown by FOSSA) + ignorePackages: + - "pip+ansible$13.2.0" + - "pip+ansible-core$2.17.7" + - "nuget+Microsoft.NET.Test.Sdk$17.6.0" + +# Notes: +# - Commit and push this file and then trigger a new FOSSA scan so the server-side +# analysis picks up the repo-level exclusions. Local changes alone won't modify +# the existing project findings until FOSSA re-analyzes the repository. +# - If your organization manages FOSSA centrally, you may need to update project +# settings in the FOSSA UI instead of relying on this file. diff --git a/NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj b/NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj index 756a2dd..31608f8 100644 --- a/NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj +++ b/NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj @@ -1,8 +1,8 @@ - + - net6.0;net7.0;net8.0 - latest + net8.0;net9.0;net10.0 + latest enable disable false @@ -15,23 +15,22 @@ - - - - - - - - - + + + + + + + + - + - + diff --git a/NETCore.Keycloak.Client.Tests/requirements.txt b/NETCore.Keycloak.Client.Tests/requirements.txt index c125c57..8f262d5 100644 --- a/NETCore.Keycloak.Client.Tests/requirements.txt +++ b/NETCore.Keycloak.Client.Tests/requirements.txt @@ -1,9 +1,9 @@ -ansible==8.7.0 -ansible-core==2.15.13 +ansible>=13.2.0 +ansible-core>=2.17.7 certifi==2024.12.14 cffi==1.17.1 charset-normalizer==3.4.1 -cryptography==44.0.0 +cryptography==44.0.1 deprecation==2.1.0 ecdsa==0.19.0 idna==3.10 @@ -11,14 +11,14 @@ importlib-resources==5.0.7 Jinja2==3.1.5 MarkupSafe==3.0.2 packaging==24.2 -pyasn1==0.6.1 +pyasn1==0.6.2 pycparser==2.22 -python-jose==3.3.0 -python-keycloak==3.3.0 +python-jose==3.4.0 +python-keycloak==7.0.2 PyYAML==6.0.2 -requests==2.32.3 +requests==2.32.4 requests-toolbelt==1.0.0 resolvelib==1.0.1 rsa==4.9 six==1.17.0 -urllib3==2.3.0 +urllib3==2.6.3 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..d7d1ad8 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. @@ -67,7 +70,7 @@ public KeycloakClient(string baseUrl, ILogger logger = null) // Remove the trailing slash from the base URL if it exists. baseUrl = baseUrl.EndsWith("/", StringComparison.Ordinal) - ? baseUrl.Remove(baseUrl.Length - 1, 1) + ? baseUrl[..^1] : baseUrl; // Define the admin API base URL for realm-specific administrative operations. @@ -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/HttpClients/KcHttpClientBase.cs b/NETCore.Keycloak.Client/HttpClients/KcHttpClientBase.cs index 2335ec5..ce24dc6 100644 --- a/NETCore.Keycloak.Client/HttpClients/KcHttpClientBase.cs +++ b/NETCore.Keycloak.Client/HttpClients/KcHttpClientBase.cs @@ -41,7 +41,7 @@ protected KcHttpClientBase(ILogger logger, string baseUrl) // Ensure the base URL does not end with a trailing slash. BaseUrl = baseUrl.EndsWith("/", StringComparison.Ordinal) - ? baseUrl.Remove(baseUrl.Length - 1, 1) + ? baseUrl[..^1] : baseUrl; Logger = 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..4a870bb 100644 --- a/NETCore.Keycloak.Client/NETCore.Keycloak.Client.csproj +++ b/NETCore.Keycloak.Client/NETCore.Keycloak.Client.csproj @@ -14,7 +14,7 @@ git README.md https://github.com/Black-Cockpit/NETCore.Keycloak - net6.0;net7.0;net8.0 + net8.0;net9.0;net10.0 latest enable disable @@ -24,21 +24,42 @@ true true All - $(NoWarn);CA1031, CA1054, CA1056, CA1865, CA1815, CA1711 + $(NoWarn);CA1031, CA1054, CA1056, CA1865, CA1815, CA1711, CA1873, IDE0040 - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +