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"),
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) {