From 0f6f469b4749e6e7cad1651f2a96d5383113a0f5 Mon Sep 17 00:00:00 2001 From: Osman Elsayed Date: Thu, 30 Oct 2025 23:20:04 +0100 Subject: [PATCH 1/2] feat: Allow the api token issuer to be a fully qualified url with a path component --- src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs | 59 +++++++------- .../ApiClient/ApiClientTests.cs | 13 +-- .../ApiClient/OAuth2ClientTests.cs | 79 ++++++++++++++----- .../ApiTokenIssuerNormalizerTests.cs | 30 +++++++ src/OpenFga.Sdk/ApiClient/OAuth2Client.cs | 18 +++-- .../Configuration/ApiTokenIssuerNormalizer.cs | 28 +++++++ .../Configuration/Configuration.cs | 1 - src/OpenFga.Sdk/Configuration/Credentials.cs | 56 ++++++++----- 8 files changed, 205 insertions(+), 79 deletions(-) create mode 100644 src/OpenFga.Sdk.Test/Configuration/ApiTokenIssuerNormalizerTests.cs create mode 100644 src/OpenFga.Sdk/Configuration/ApiTokenIssuerNormalizer.cs diff --git a/src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs b/src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs index 010a75f..66998b6 100644 --- a/src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs +++ b/src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs @@ -16,6 +16,7 @@ using System.Threading; using System.Threading.Tasks; using Xunit; +using SdkConfiguration = OpenFga.Sdk.Configuration.Configuration; namespace OpenFga.Sdk.Test.Api { /// @@ -24,11 +25,11 @@ namespace OpenFga.Sdk.Test.Api { public class OpenFgaApiTests : IDisposable { private readonly string _storeId; private readonly string _host = "api.fga.example"; - private readonly Configuration.Configuration _config; + private readonly SdkConfiguration _config; public OpenFgaApiTests() { _storeId = "01H0H015178Y2V4CX10C2KGHF4"; - _config = new Configuration.Configuration() { ApiHost = _host }; + _config = new SdkConfiguration { ApiHost = _host }; } private HttpResponseMessage GetCheckResponse(CheckResponse content, bool shouldRetry = false) { @@ -56,7 +57,7 @@ public void Dispose() { /// [Fact] public void StoreIdNotRequired() { - var storeIdRequiredConfig = new Configuration.Configuration() { ApiHost = _host }; + var storeIdRequiredConfig = new SdkConfiguration { ApiHost = _host }; storeIdRequiredConfig.EnsureValid(); } @@ -65,7 +66,7 @@ public void StoreIdNotRequired() { /// [Fact] public async Task StoreIdRequiredWhenNeeded() { - var config = new Configuration.Configuration() { ApiHost = _host }; + var config = new SdkConfiguration { ApiHost = _host }; var openFgaApi = new OpenFgaApi(config); async Task ActionMissingStoreId() => await openFgaApi.ReadAuthorizationModels(null, null); @@ -78,7 +79,7 @@ public async Task StoreIdRequiredWhenNeeded() { // /// [Fact] public void ValidHostRequired() { - var config = new Configuration.Configuration() { }; + var config = new SdkConfiguration(); void ActionMissingHost() => config.EnsureValid(); var exception = Assert.Throws(ActionMissingHost); Assert.Equal("Required parameter ApiUrl was not defined when calling Configuration.", exception.Message); @@ -89,7 +90,7 @@ public void ValidHostRequired() { // /// [Fact] public void ValidHostWellFormed() { - var config = new Configuration.Configuration() { ApiHost = "https://api.fga.example" }; + var config = new SdkConfiguration { ApiHost = "https://api.fga.example" }; void ActionMalformedHost() => config.EnsureValid(); var exception = Assert.Throws(ActionMalformedHost); Assert.Equal("Configuration.ApiUrl (https://https://api.fga.example) does not form a valid URI (https://https://api.fga.example)", exception.Message); @@ -100,7 +101,7 @@ public void ValidHostWellFormed() { /// [Fact] public void ApiTokenRequired() { - var missingApiTokenConfig = new Configuration.Configuration() { + var missingApiTokenConfig = new SdkConfiguration { ApiHost = _host, Credentials = new Credentials() { Method = CredentialsMethod.ApiToken, @@ -119,14 +120,14 @@ void ActionMissingApiToken() => // /// [Fact] public void ValidApiTokenIssuerWellFormed() { - var config = new Configuration.Configuration() { + var config = new SdkConfiguration { ApiHost = _host, Credentials = new Credentials() { Method = CredentialsMethod.ClientCredentials, Config = new CredentialsConfig() { ClientId = "some-id", ClientSecret = "some-secret", - ApiTokenIssuer = "https://tokenissuer.fga.example", + ApiTokenIssuer = "https://https://tokenissuer.fga.example", ApiAudience = "some-audience", } } @@ -142,7 +143,7 @@ public void ValidApiTokenIssuerWellFormed() { [Fact] public async Task ApiTokenSentInHeader() { var mockHandler = new Mock(MockBehavior.Strict); - var config = new Configuration.Configuration() { + var config = new SdkConfiguration { ApiHost = _host, Credentials = new Credentials() { Method = CredentialsMethod.ApiToken, @@ -189,7 +190,7 @@ public async Task ApiTokenSentInHeader() { /// [Fact] public void ApiTokenDoesNotSetReservedHeaderInDefaultHeaders() { - var config = new Configuration.Configuration() { + var config = new SdkConfiguration { ApiHost = _host, Credentials = new Credentials() { Method = CredentialsMethod.ApiToken, @@ -211,7 +212,7 @@ public void ApiTokenDoesNotSetReservedHeaderInDefaultHeaders() { /// [Fact] public void ClientIdClientSecretRequired() { - var missingClientIdConfig = new Configuration.Configuration() { + var missingClientIdConfig = new SdkConfiguration { StoreId = _storeId, ApiHost = _host, Credentials = new Credentials() { @@ -230,7 +231,7 @@ void ActionMissingClientId() => Assert.Equal("Required parameter ClientId was not defined when calling Configuration.", exceptionMissingClientId.Message); - var missingClientSecretConfig = new Configuration.Configuration() { + var missingClientSecretConfig = new SdkConfiguration { StoreId = _storeId, ApiHost = _host, Credentials = new Credentials() { @@ -249,7 +250,7 @@ void ActionMissingClientSecret() => Assert.Equal("Required parameter ClientSecret was not defined when calling Configuration.", exceptionMissingClientSecret.Message); - var missingApiTokenIssuerConfig = new Configuration.Configuration() { + var missingApiTokenIssuerConfig = new SdkConfiguration { StoreId = _storeId, ApiHost = _host, Credentials = new Credentials() { @@ -268,7 +269,7 @@ void ActionMissingApiTokenIssuer() => Assert.Equal("Required parameter ApiTokenIssuer was not defined when calling Configuration.", exceptionMissingApiTokenIssuer.Message); - var missingApiAudienceConfig = new Configuration.Configuration() { + var missingApiAudienceConfig = new SdkConfiguration { StoreId = _storeId, ApiHost = _host, Credentials = new Credentials() { @@ -294,7 +295,7 @@ void ActionMissingApiAudience() => /// [Fact] public async Task ExchangeCredentialsTest() { - var config = new Configuration.Configuration() { + var config = new SdkConfiguration { StoreId = _storeId, ApiHost = _host, Credentials = new Credentials() { @@ -394,7 +395,7 @@ public async Task ExchangeCredentialsTest() { /// [Fact] public async Task ExchangeCredentialsAfterExpiryTest() { - var config = new Configuration.Configuration() { + var config = new SdkConfiguration { ApiHost = _host, Credentials = new Credentials() { Method = CredentialsMethod.ClientCredentials, @@ -493,7 +494,7 @@ public async Task ExchangeCredentialsAfterExpiryTest() { /// [Fact] public async Task ExchangeCredentialsRetriesTest() { - var config = new Configuration.Configuration() { + var config = new SdkConfiguration { ApiHost = _host, Credentials = new Credentials() { Method = CredentialsMethod.ClientCredentials, @@ -658,7 +659,7 @@ public async Task FgaApiInternalErrorTest() { var httpClient = new HttpClient(mockHandler.Object); - var config = new Configuration.Configuration() { + var config = new SdkConfiguration { ApiHost = _host, MaxRetry = 1, }; @@ -856,7 +857,7 @@ public async Task FgaApiRateLimitExceededErrorTest() { .ReturnsAsync(GetCheckResponse(new CheckResponse { Allowed = true }, true)); var httpClient = new HttpClient(mockHandler.Object); - var config = new Configuration.Configuration() { + var config = new SdkConfiguration { StoreId = _storeId, ApiHost = _host, MaxRetry = 5, @@ -954,7 +955,7 @@ public async Task RetryOnRateLimitTest() { var httpClient = new HttpClient(mockHandler.Object); - var config = new Configuration.Configuration() { + var config = new SdkConfiguration { StoreId = _storeId, ApiHost = _host, MaxRetry = 3, @@ -2018,7 +2019,7 @@ public async Task RetryAfterIntegerFormatInErrorTest() { }); var httpClient = new HttpClient(mockHandler.Object); - var config = new Configuration.Configuration() { + var config = new SdkConfiguration { ApiHost = _host, MaxRetry = 2, }; @@ -2090,7 +2091,7 @@ public async Task RetryAfterHttpDateFormatInErrorTest() { }); var httpClient = new HttpClient(mockHandler.Object); - var config = new Configuration.Configuration() { + var config = new SdkConfiguration { ApiHost = _host, MaxRetry = 1, }; @@ -2198,7 +2199,7 @@ public async Task RetryAfterOutOfBoundsTooLargeTest() { }); var httpClient = new HttpClient(mockHandler.Object); - var config = new Configuration.Configuration() { + var config = new SdkConfiguration { ApiHost = _host, MaxRetry = 1, }; @@ -2257,7 +2258,7 @@ public async Task RetryAfterOutOfBoundsTooSmallTest() { }); var httpClient = new HttpClient(mockHandler.Object); - var config = new Configuration.Configuration() { + var config = new SdkConfiguration { ApiHost = _host, MaxRetry = 1, }; @@ -2320,7 +2321,7 @@ public async Task RetryAfterInvalidFormatTest() { }); var httpClient = new HttpClient(mockHandler.Object); - var config = new Configuration.Configuration() { + var config = new SdkConfiguration { ApiHost = _host, MaxRetry = 1, }; @@ -2383,7 +2384,7 @@ public async Task RetryAttemptTrackingTest() { }); var httpClient = new HttpClient(mockHandler.Object); - var config = new Configuration.Configuration() { + var config = new SdkConfiguration { ApiHost = _host, MaxRetry = 2, }; @@ -2437,7 +2438,7 @@ public async Task RetryAfterWith500ErrorTest() { }); var httpClient = new HttpClient(mockHandler.Object); - var config = new Configuration.Configuration() { + var config = new SdkConfiguration { ApiHost = _host, MaxRetry = 1, }; @@ -2611,7 +2612,7 @@ public async Task XRateLimitResetLegacyHeaderTest() { }); var httpClient = new HttpClient(mockHandler.Object); - var config = new Configuration.Configuration() { + var config = new SdkConfiguration { ApiHost = _host, MaxRetry = 1, }; diff --git a/src/OpenFga.Sdk.Test/ApiClient/ApiClientTests.cs b/src/OpenFga.Sdk.Test/ApiClient/ApiClientTests.cs index a4f3798..c783ea6 100644 --- a/src/OpenFga.Sdk.Test/ApiClient/ApiClientTests.cs +++ b/src/OpenFga.Sdk.Test/ApiClient/ApiClientTests.cs @@ -22,6 +22,7 @@ using System.Threading; using System.Threading.Tasks; using Xunit; +using SdkConfiguration = OpenFga.Sdk.Configuration.Configuration; namespace OpenFga.Sdk.Test.ApiClient { /// @@ -42,12 +43,12 @@ public class ApiClientTests { /// /// Creates a Configuration with ApiToken credentials /// - private static Configuration.Configuration CreateApiTokenConfiguration(string apiToken = TestApiToken) { + private static SdkConfiguration CreateApiTokenConfiguration(string apiToken = TestApiToken) { var credentialsConfig = new CredentialsConfig { ApiToken = apiToken }; - return new Configuration.Configuration { + return new SdkConfiguration { ApiHost = TestHost, Credentials = new Credentials { Method = CredentialsMethod.ApiToken, @@ -59,7 +60,7 @@ private static Configuration.Configuration CreateApiTokenConfiguration(string ap /// /// Creates a Configuration with OAuth ClientCredentials /// - private static Configuration.Configuration CreateOAuthConfiguration() { + private static SdkConfiguration CreateOAuthConfiguration() { var credentialsConfig = new CredentialsConfig { ClientId = TestClientId, ClientSecret = TestClientSecret, @@ -67,7 +68,7 @@ private static Configuration.Configuration CreateOAuthConfiguration() { ApiAudience = TestAudience }; - return new Configuration.Configuration { + return new SdkConfiguration { ApiHost = TestHost, Credentials = new Credentials { Method = CredentialsMethod.ClientCredentials, @@ -79,8 +80,8 @@ private static Configuration.Configuration CreateOAuthConfiguration() { /// /// Creates a Configuration with no credentials /// - private static Configuration.Configuration CreateNoCredentialsConfiguration() { - return new Configuration.Configuration { + private static SdkConfiguration CreateNoCredentialsConfiguration() { + return new SdkConfiguration { ApiHost = TestHost }; } diff --git a/src/OpenFga.Sdk.Test/ApiClient/OAuth2ClientTests.cs b/src/OpenFga.Sdk.Test/ApiClient/OAuth2ClientTests.cs index 2450892..c99ba7c 100644 --- a/src/OpenFga.Sdk.Test/ApiClient/OAuth2ClientTests.cs +++ b/src/OpenFga.Sdk.Test/ApiClient/OAuth2ClientTests.cs @@ -1,16 +1,3 @@ -// -// OpenFGA/.NET SDK for OpenFGA -// -// API version: 1.x -// Website: https://openfga.dev -// Documentation: https://openfga.dev/docs -// Support: https://openfga.dev/community -// License: [Apache-2.0](https://github.com/openfga/dotnet-sdk/blob/main/LICENSE) -// -// NOTE: This file was auto generated. DO NOT EDIT. -// - - using Moq; using Moq.Protected; using OpenFga.Sdk.ApiClient; @@ -25,6 +12,7 @@ using System.Threading; using System.Threading.Tasks; using Xunit; +using SdkConfiguration = OpenFga.Sdk.Configuration.Configuration; namespace OpenFga.Sdk.Test.ApiClient { /// @@ -117,7 +105,7 @@ public async Task OAuth2_ExchangeToken_RetriesOn429RateLimit() { }); var httpClient = new HttpClient(mockHandler.Object); - var configuration = new Configuration.Configuration { ApiUrl = "https://api.example.com" }; + var configuration = new SdkConfiguration { ApiUrl = "https://api.example.com" }; var baseClient = new BaseClient(configuration, httpClient); var metrics = new Metrics(configuration); @@ -154,7 +142,7 @@ public async Task OAuth2_ExchangeToken_RetriesOn500ServerError() { }); var httpClient = new HttpClient(mockHandler.Object); - var configuration = new Configuration.Configuration { ApiUrl = "https://api.example.com" }; + var configuration = new SdkConfiguration { ApiUrl = "https://api.example.com" }; var baseClient = new BaseClient(configuration, httpClient); var metrics = new Metrics(configuration); @@ -193,7 +181,7 @@ public async Task OAuth2_ExchangeToken_RespectsRetryAfterHeader() { }); var httpClient = new HttpClient(mockHandler.Object); - var configuration = new Configuration.Configuration { ApiUrl = "https://api.example.com" }; + var configuration = new SdkConfiguration { ApiUrl = "https://api.example.com" }; var baseClient = new BaseClient(configuration, httpClient); var metrics = new Metrics(configuration); @@ -233,7 +221,7 @@ public async Task OAuth2_ExchangeToken_SucceedsAfterRetries() { }); var httpClient = new HttpClient(mockHandler.Object); - var configuration = new Configuration.Configuration { ApiUrl = "https://api.example.com" }; + var configuration = new SdkConfiguration { ApiUrl = "https://api.example.com" }; var baseClient = new BaseClient(configuration, httpClient); var metrics = new Metrics(configuration); @@ -268,7 +256,7 @@ public async Task OAuth2_ExchangeToken_FailsAfterMaxRetries() { }); var httpClient = new HttpClient(mockHandler.Object); - var configuration = new Configuration.Configuration { ApiUrl = "https://api.example.com" }; + var configuration = new SdkConfiguration { ApiUrl = "https://api.example.com" }; var baseClient = new BaseClient(configuration, httpClient); var metrics = new Metrics(configuration); @@ -303,7 +291,7 @@ public async Task OAuth2_ExchangeToken_RetriesOnNetworkError() { }); var httpClient = new HttpClient(mockHandler.Object); - var configuration = new Configuration.Configuration { ApiUrl = "https://api.example.com" }; + var configuration = new SdkConfiguration { ApiUrl = "https://api.example.com" }; var baseClient = new BaseClient(configuration, httpClient); var metrics = new Metrics(configuration); @@ -316,5 +304,58 @@ public async Task OAuth2_ExchangeToken_RetriesOnNetworkError() { } #endregion + + #region ApiTokenIssuer Path Handling + + [Theory] + // No scheme should be normalized to https and use default path + [InlineData("issuer.fga.example", "https://issuer.fga.example/oauth/token")] + [InlineData("issuer.fga.example:8080", "https://issuer.fga.example:8080/oauth/token")] + // No scheme with custom path should be normalized to https and keep custom path + [InlineData("issuer.fga.example/custom/token", "https://issuer.fga.example/custom/token")] + [InlineData("issuer.fga.example:8080/custom/token", "https://issuer.fga.example:8080/custom/token")] + // Domain without path should use default /oauth/token + [InlineData("https://issuer.fga.example", "https://issuer.fga.example/oauth/token")] + [InlineData("http://issuer.fga.example", "http://issuer.fga.example/oauth/token")] + // Domain with trailing slash should use default /oauth/token + [InlineData("https://issuer.fga.example/", "https://issuer.fga.example/oauth/token")] + // Domain with port should use default /oauth/token + [InlineData("https://issuer.fga.example:8080", "https://issuer.fga.example:8080/oauth/token")] + [InlineData("https://issuer.fga.example:8080/", "https://issuer.fga.example:8080/oauth/token")] + // Domain with custom path should keep the custom path + [InlineData("https://issuer.fga.example/custom/token", "https://issuer.fga.example/custom/token")] + [InlineData("https://issuer.fga.example:8080/custom/token", "https://issuer.fga.example:8080/custom/token")] + public async Task OAuth2_Constructor_SetsCorrectTokenIssuerPath(string tokenIssuer, string expectedPath) { + // Arrange + var credentials = CreateTestCredentials(tokenIssuer: tokenIssuer); + + string? actualRequestUri = null; + var mockHandler = new Mock(); + mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync((HttpRequestMessage request, CancellationToken ct) => { + actualRequestUri = request.RequestUri?.ToString(); + return CreateTokenResponse(); + }); + + var httpClient = new HttpClient(mockHandler.Object); + var configuration = new SdkConfiguration { ApiUrl = "https://api.example.com" }; + var baseClient = new BaseClient(configuration, httpClient); + var metrics = new Metrics(configuration); + var retryParams = CreateTestRetryParams(); + + // Act + var oauth2Client = new OAuth2Client(credentials, baseClient, retryParams, metrics); + await oauth2Client.GetAccessTokenAsync(); + + // Assert + Assert.Equal(expectedPath, actualRequestUri); + } + + #endregion } } \ No newline at end of file diff --git a/src/OpenFga.Sdk.Test/Configuration/ApiTokenIssuerNormalizerTests.cs b/src/OpenFga.Sdk.Test/Configuration/ApiTokenIssuerNormalizerTests.cs new file mode 100644 index 0000000..b49e2f6 --- /dev/null +++ b/src/OpenFga.Sdk.Test/Configuration/ApiTokenIssuerNormalizerTests.cs @@ -0,0 +1,30 @@ +using OpenFga.Sdk.Configuration; +using Xunit; + +namespace OpenFga.Sdk.Test.Configuration { + public class ApiTokenIssuerNormalizerTests { + + [Theory] + // Null and empty input tests + [InlineData(null, null)] + [InlineData("", null)] + [InlineData(" ", null)] + // No scheme tests + [InlineData("issuer.fga.example", "https://issuer.fga.example")] + [InlineData("issuer.fga.example:8080", "https://issuer.fga.example:8080")] + [InlineData("issuer.fga.example/some_endpoint", "https://issuer.fga.example/some_endpoint")] + [InlineData("issuer.fga.example:8080/some_endpoint", "https://issuer.fga.example:8080/some_endpoint")] + // HTTPS scheme tests + [InlineData("https://issuer.fga.example", "https://issuer.fga.example")] + // HTTP scheme tests + [InlineData("http://issuer.fga.example", "http://issuer.fga.example")] + public void Normalize_ReturnsExpectedResult(string input, string expected) { + // Act + var result = ApiTokenIssuerNormalizer.Normalize(input); + + // Assert + Assert.Equal(expected, result); + } + } +} + diff --git a/src/OpenFga.Sdk/ApiClient/OAuth2Client.cs b/src/OpenFga.Sdk/ApiClient/OAuth2Client.cs index 547cfe7..ee80daa 100644 --- a/src/OpenFga.Sdk/ApiClient/OAuth2Client.cs +++ b/src/OpenFga.Sdk/ApiClient/OAuth2Client.cs @@ -19,10 +19,11 @@ namespace OpenFga.Sdk.ApiClient; /// public class OAuth2Client { private const int TOKEN_EXPIRY_BUFFER_THRESHOLD_IN_SEC = FgaConstants.TokenExpiryThresholdBufferInSec; - private const int TOKEN_EXPIRY_JITTER_IN_SEC = FgaConstants.TokenExpiryJitterInSec; // We add some jitter so that token refreshes are less likely to collide + private const string DEFAULT_API_TOKEN_ISSUER_PATH = "/oauth/token"; + private static readonly Random _random = new(); private readonly Metrics metrics; @@ -97,12 +98,19 @@ public OAuth2Client(Credentials credentialsConfig, BaseClient httpClient, RetryP throw new FgaRequiredParamError("OAuth2Client", "config.ClientSecret"); } - if (string.IsNullOrWhiteSpace(credentialsConfig.Config.ApiTokenIssuer)) { + string? normalizedApiTokenIssuer = ApiTokenIssuerNormalizer.Normalize(credentialsConfig.Config.ApiTokenIssuer); + if (string.IsNullOrWhiteSpace(normalizedApiTokenIssuer)) { throw new FgaRequiredParamError("OAuth2Client", "config.ApiTokenIssuer"); } _httpClient = httpClient; - _apiTokenIssuer = credentialsConfig.Config.ApiTokenIssuer; + + var normalizedApiTokenIssuerUri = new Uri(normalizedApiTokenIssuer, UriKind.Absolute); + var normalizedApiTokenIssuerUriWithPath = string.IsNullOrWhiteSpace(normalizedApiTokenIssuerUri.AbsolutePath) || normalizedApiTokenIssuerUri.AbsolutePath.Equals("/") + ? new Uri(normalizedApiTokenIssuerUri, DEFAULT_API_TOKEN_ISSUER_PATH) + : normalizedApiTokenIssuerUri; + _apiTokenIssuer = normalizedApiTokenIssuerUriWithPath.ToString(); + _authRequest = new Dictionary { { "client_id", credentialsConfig.Config.ClientId }, { "client_secret", credentialsConfig.Config.ClientSecret }, @@ -125,8 +133,8 @@ public OAuth2Client(Credentials credentialsConfig, BaseClient httpClient, RetryP private async Task ExchangeTokenAsync(CancellationToken cancellationToken = default) { var requestBuilder = new RequestBuilder> { Method = HttpMethod.Post, - BasePath = $"https://{_apiTokenIssuer}", - PathTemplate = "/oauth/token", + BasePath = _apiTokenIssuer, + PathTemplate = "", Body = _authRequest, ContentType = "application/x-www-form-urlencode" }; diff --git a/src/OpenFga.Sdk/Configuration/ApiTokenIssuerNormalizer.cs b/src/OpenFga.Sdk/Configuration/ApiTokenIssuerNormalizer.cs new file mode 100644 index 0000000..e78d2b8 --- /dev/null +++ b/src/OpenFga.Sdk/Configuration/ApiTokenIssuerNormalizer.cs @@ -0,0 +1,28 @@ +using System; + +namespace OpenFga.Sdk.Configuration; + +internal static class ApiTokenIssuerNormalizer { + + /// + /// Normalizes the API Token Issuer to a full URL. + /// If the input doesn't start with http:// or https://, prepends https://. + /// + /// The API token issuer (domain, domain with path, or full URL) + /// The normalized full URL + internal static string? Normalize(string? apiTokenIssuer) { + if (string.IsNullOrWhiteSpace(apiTokenIssuer)) { + return null; + } + + var normalizedUrl = apiTokenIssuer; + + // If no scheme is provided, prepend https:// + if (!normalizedUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !normalizedUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { + normalizedUrl = $"https://{normalizedUrl}"; + } + + return normalizedUrl; + } +} diff --git a/src/OpenFga.Sdk/Configuration/Configuration.cs b/src/OpenFga.Sdk/Configuration/Configuration.cs index 7146fe4..7d93451 100644 --- a/src/OpenFga.Sdk/Configuration/Configuration.cs +++ b/src/OpenFga.Sdk/Configuration/Configuration.cs @@ -130,7 +130,6 @@ public void EnsureValid() { #endregion Constants - #region Properties /// diff --git a/src/OpenFga.Sdk/Configuration/Credentials.cs b/src/OpenFga.Sdk/Configuration/Credentials.cs index 8a62f33..8d67e36 100644 --- a/src/OpenFga.Sdk/Configuration/Credentials.cs +++ b/src/OpenFga.Sdk/Configuration/Credentials.cs @@ -40,25 +40,40 @@ public interface IClientCredentialsConfig { /// Gets or sets the Client ID. /// /// Client ID. - public string? ClientId { get; set; } + string? ClientId { get; set; } /// /// Gets or sets the Client Secret. /// /// Client Secret. - public string? ClientSecret { get; set; } + string? ClientSecret { get; set; } /// /// Gets or sets the API Token Issuer. + /// + /// The SDK automatically normalizes URLs: + /// + /// URLs without a scheme are prefixed with "https://" + /// If no path is provided (or only "/"), defaults to "/oauth/token" + /// Otherwise, the path is preserved + /// + /// + /// Examples: + /// + /// "issuer.fga.example" → "https://issuer.fga.example/oauth/token" + /// "https://issuer.fga.example" → "https://issuer.fga.example/oauth/token" + /// "https://issuer.fga.example/custom/token" → "https://issuer.fga.example/custom/token" + /// + /// /// /// API Token Issuer. - public string? ApiTokenIssuer { get; set; } + string? ApiTokenIssuer { get; set; } /// /// Gets or sets the API Audience. /// /// API Audience. - public string? ApiAudience { get; set; } + string? ApiAudience { get; set; } } public interface ICredentialsConfig : IClientCredentialsConfig, IApiTokenConfig { } @@ -80,6 +95,16 @@ public interface IAuthCredentialsConfig { /// /// public class Credentials : IAuthCredentialsConfig { + + /// + /// Initializes a new instance of the class + /// + /// + /// + public Credentials() { + EnsureValid(); + } + /// /// credential methods /// @@ -90,12 +115,6 @@ public class Credentials : IAuthCredentialsConfig { /// public ICredentialsConfig? Config { get; set; } - private static bool IsWellFormedUriString(string uri) { - return Uri.TryCreate(uri, UriKind.Absolute, out var uriResult) && - ((uriResult.ToString().Equals(uri) || uriResult.ToString().Equals($"{uri}/")) && - (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps)); - } - /// /// Ensures the credentials configuration is valid otherwise throws an error /// @@ -125,9 +144,10 @@ public void EnsureValid() { throw new FgaRequiredParamError("Configuration", nameof(Config.ApiAudience)); } - if (!string.IsNullOrWhiteSpace(Config?.ApiTokenIssuer) && !IsWellFormedUriString($"https://{Config.ApiTokenIssuer}")) { - throw new FgaValidationError( - $"Configuration.ApiTokenIssuer does not form a valid URI (https://{Config.ApiTokenIssuer})"); + // Validate that the normalized URL is well-formed + var normalizedApiTokenIssuer = ApiTokenIssuerNormalizer.Normalize(Config?.ApiTokenIssuer); + if (!string.IsNullOrWhiteSpace(normalizedApiTokenIssuer) && !IsWellFormedUriString(normalizedApiTokenIssuer)) { + throw new FgaValidationError($"Configuration.ApiTokenIssuer does not form a valid URI ({normalizedApiTokenIssuer})"); } break; @@ -137,12 +157,10 @@ public void EnsureValid() { } } - /// - /// Initializes a new instance of the class - /// - /// - public Credentials() { - this.EnsureValid(); + private static bool IsWellFormedUriString(string? uri) { + return Uri.TryCreate(uri, UriKind.Absolute, out var uriResult) && + ((uriResult.ToString().Equals(uri) || uriResult.ToString().Equals($"{uri}/")) && + (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps)); } static Credentials Init(IAuthCredentialsConfig config) { From 3aeeaeac7d5216d549e1dcccb841daf1d845f04c Mon Sep 17 00:00:00 2001 From: Osman Elsayed Date: Tue, 4 Nov 2025 16:08:17 +0100 Subject: [PATCH 2/2] chore: Address PR comment --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 30d1f62..a1143c0 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,10 @@ namespace Example { Credentials = new Credentials() { Method = CredentialsMethod.ClientCredentials, Config = new CredentialsConfig() { + // API Token Issuer can contain: + // - a scheme, defaults to https + // - a path, defaults to /oauth/token + // - a port ApiTokenIssuer = Environment.GetEnvironmentVariable("FGA_API_TOKEN_ISSUER"), ApiAudience = Environment.GetEnvironmentVariable("FGA_API_AUDIENCE"), ClientId = Environment.GetEnvironmentVariable("FGA_CLIENT_ID"),