From 5494afc2032704cca490982e790582d84b466874 Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Mon, 18 May 2026 11:49:17 +0530 Subject: [PATCH 1/2] feat: support OAuth2 scopes for client credentials authentication - Add optional `Scopes` property to `IClientCredentialsConfig` and `CredentialsConfig` - Include `scope` parameter in OAuth2 token exchange request when configured - Make `ApiAudience` optional (standard OAuth2 servers don't require it) - Add tests for scopes inclusion, omission, and combined audience+scopes --- src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs | 9 +- .../ApiClient/OAuth2ClientTests.cs | 153 ++++++++++++++++++ src/OpenFga.Sdk/ApiClient/OAuth2Client.cs | 6 +- src/OpenFga.Sdk/Configuration/Credentials.cs | 15 +- 4 files changed, 172 insertions(+), 11 deletions(-) diff --git a/src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs b/src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs index b8cead86..e87aa662 100644 --- a/src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs +++ b/src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs @@ -269,6 +269,7 @@ void ActionMissingApiTokenIssuer() => Assert.Equal("Required parameter ApiTokenIssuer was not defined when calling Configuration.", exceptionMissingApiTokenIssuer.Message); + // ApiAudience is optional — standard OAuth2 servers don't require it var missingApiAudienceConfig = new SdkConfiguration { StoreId = _storeId, ApiHost = _host, @@ -282,12 +283,8 @@ void ActionMissingApiTokenIssuer() => } }; - void ActionMissingApiAudience() => - missingApiAudienceConfig.EnsureValid(); - var exceptionMissingApiAudience = - Assert.Throws(ActionMissingApiAudience); - Assert.Equal("Required parameter ApiAudience was not defined when calling Configuration.", - exceptionMissingApiAudience.Message); + var exception = Record.Exception(() => missingApiAudienceConfig.EnsureValid()); + Assert.Null(exception); } /// diff --git a/src/OpenFga.Sdk.Test/ApiClient/OAuth2ClientTests.cs b/src/OpenFga.Sdk.Test/ApiClient/OAuth2ClientTests.cs index 8918aa73..003736ed 100644 --- a/src/OpenFga.Sdk.Test/ApiClient/OAuth2ClientTests.cs +++ b/src/OpenFga.Sdk.Test/ApiClient/OAuth2ClientTests.cs @@ -307,6 +307,159 @@ public async Task OAuth2_ExchangeToken_RetriesOnNetworkError() { #endregion + #region OAuth2 Scopes + + [Fact] + public async Task OAuth2_ExchangeToken_IncludesScopesInRequest() { + var credentials = new Credentials { + Method = CredentialsMethod.ClientCredentials, + Config = new CredentialsConfig { + ClientId = TestClientId, + ClientSecret = TestClientSecret, + ApiTokenIssuer = TestTokenIssuer, + ApiAudience = TestAudience, + Scopes = "scope1 scope2" + } + }; + var retryParams = CreateTestRetryParams(maxRetry: 0, minWaitInMs: 10); + + string? requestBody = null; + var mockHandler = new Mock(); + mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync((HttpRequestMessage request, CancellationToken ct) => { + requestBody = request.Content?.ReadAsStringAsync().Result; + return CreateTokenResponse(); + }); + + var httpClient = new HttpClient(mockHandler.Object); + var configuration = new SdkConfiguration { ApiUrl = Constants.FgaConstants.TestApiUrl }; + var baseClient = new BaseClient(configuration, httpClient); + var metrics = new Metrics(configuration); + + var oauth2Client = new OAuth2Client(credentials, baseClient, retryParams, metrics); + await oauth2Client.GetAccessTokenAsync(); + + Assert.NotNull(requestBody); + Assert.Contains("scope=scope1+scope2", requestBody); + } + + [Fact] + public async Task OAuth2_ExchangeToken_OmitsScopeWhenNotConfigured() { + var credentials = CreateTestCredentials(); + var retryParams = CreateTestRetryParams(maxRetry: 0, minWaitInMs: 10); + + string? requestBody = null; + var mockHandler = new Mock(); + mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync((HttpRequestMessage request, CancellationToken ct) => { + requestBody = request.Content?.ReadAsStringAsync().Result; + return CreateTokenResponse(); + }); + + var httpClient = new HttpClient(mockHandler.Object); + var configuration = new SdkConfiguration { ApiUrl = Constants.FgaConstants.TestApiUrl }; + var baseClient = new BaseClient(configuration, httpClient); + var metrics = new Metrics(configuration); + + var oauth2Client = new OAuth2Client(credentials, baseClient, retryParams, metrics); + await oauth2Client.GetAccessTokenAsync(); + + Assert.NotNull(requestBody); + Assert.DoesNotContain("scope=", requestBody); + } + + [Fact] + public async Task OAuth2_ExchangeToken_WorksWithoutAudience() { + var credentials = new Credentials { + Method = CredentialsMethod.ClientCredentials, + Config = new CredentialsConfig { + ClientId = TestClientId, + ClientSecret = TestClientSecret, + ApiTokenIssuer = TestTokenIssuer, + Scopes = "read write" + } + }; + var retryParams = CreateTestRetryParams(maxRetry: 0, minWaitInMs: 10); + + string? requestBody = null; + var mockHandler = new Mock(); + mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync((HttpRequestMessage request, CancellationToken ct) => { + requestBody = request.Content?.ReadAsStringAsync().Result; + return CreateTokenResponse(); + }); + + var httpClient = new HttpClient(mockHandler.Object); + var configuration = new SdkConfiguration { ApiUrl = Constants.FgaConstants.TestApiUrl }; + var baseClient = new BaseClient(configuration, httpClient); + var metrics = new Metrics(configuration); + + var oauth2Client = new OAuth2Client(credentials, baseClient, retryParams, metrics); + var token = await oauth2Client.GetAccessTokenAsync(); + + Assert.Equal("test-access-token", token); + Assert.NotNull(requestBody); + Assert.DoesNotContain("audience=", requestBody); + Assert.Contains("scope=read+write", requestBody); + } + + [Fact] + public async Task OAuth2_ExchangeToken_IncludesBothAudienceAndScopes() { + var credentials = new Credentials { + Method = CredentialsMethod.ClientCredentials, + Config = new CredentialsConfig { + ClientId = TestClientId, + ClientSecret = TestClientSecret, + ApiTokenIssuer = TestTokenIssuer, + ApiAudience = TestAudience, + Scopes = "openid profile" + } + }; + var retryParams = CreateTestRetryParams(maxRetry: 0, minWaitInMs: 10); + + string? requestBody = null; + var mockHandler = new Mock(); + mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync((HttpRequestMessage request, CancellationToken ct) => { + requestBody = request.Content?.ReadAsStringAsync().Result; + return CreateTokenResponse(); + }); + + var httpClient = new HttpClient(mockHandler.Object); + var configuration = new SdkConfiguration { ApiUrl = Constants.FgaConstants.TestApiUrl }; + var baseClient = new BaseClient(configuration, httpClient); + var metrics = new Metrics(configuration); + + var oauth2Client = new OAuth2Client(credentials, baseClient, retryParams, metrics); + await oauth2Client.GetAccessTokenAsync(); + + Assert.NotNull(requestBody); + Assert.Contains("audience=test-audience", requestBody); + Assert.Contains("scope=openid+profile", requestBody); + } + + #endregion + #region ApiTokenIssuer Path Handling [Theory] diff --git a/src/OpenFga.Sdk/ApiClient/OAuth2Client.cs b/src/OpenFga.Sdk/ApiClient/OAuth2Client.cs index f94eb0ff..83a2c8c1 100644 --- a/src/OpenFga.Sdk/ApiClient/OAuth2Client.cs +++ b/src/OpenFga.Sdk/ApiClient/OAuth2Client.cs @@ -131,10 +131,14 @@ public OAuth2Client(Credentials credentialsConfig, BaseClient httpClient, RetryP { "grant_type", "client_credentials" } }; - if (credentialsConfig.Config.ApiAudience != null) { + if (!string.IsNullOrWhiteSpace(credentialsConfig.Config.ApiAudience)) { _authRequest["audience"] = credentialsConfig.Config.ApiAudience; } + if (!string.IsNullOrWhiteSpace(credentialsConfig.Config.Scopes)) { + _authRequest["scope"] = credentialsConfig.Config.Scopes; + } + _retryHandler = new RetryHandler(retryParams); this.metrics = metrics; } diff --git a/src/OpenFga.Sdk/Configuration/Credentials.cs b/src/OpenFga.Sdk/Configuration/Credentials.cs index 8d67e364..6af3f795 100644 --- a/src/OpenFga.Sdk/Configuration/Credentials.cs +++ b/src/OpenFga.Sdk/Configuration/Credentials.cs @@ -71,9 +71,19 @@ public interface IClientCredentialsConfig { /// /// Gets or sets the API Audience. + /// Optional — only required for Auth0-style token servers. + /// Standard OAuth2 servers typically do not use this parameter. /// /// API Audience. string? ApiAudience { get; set; } + + /// + /// Gets or sets the OAuth2 scopes to request during the client credentials token exchange. + /// Space-separated string of scopes (e.g. "scope1 scope2"). + /// Optional. + /// + /// OAuth2 Scopes. + string? Scopes { get; set; } } public interface ICredentialsConfig : IClientCredentialsConfig, IApiTokenConfig { } @@ -83,6 +93,7 @@ public struct CredentialsConfig : ICredentialsConfig { public string? ClientSecret { get; set; } public string? ApiTokenIssuer { get; set; } public string? ApiAudience { get; set; } + public string? Scopes { get; set; } public string? ApiToken { get; set; } } @@ -140,10 +151,6 @@ public void EnsureValid() { throw new FgaRequiredParamError("Configuration", nameof(Config.ApiTokenIssuer)); } - if (string.IsNullOrWhiteSpace(Config?.ApiAudience)) { - throw new FgaRequiredParamError("Configuration", nameof(Config.ApiAudience)); - } - // Validate that the normalized URL is well-formed var normalizedApiTokenIssuer = ApiTokenIssuerNormalizer.Normalize(Config?.ApiTokenIssuer); if (!string.IsNullOrWhiteSpace(normalizedApiTokenIssuer) && !IsWellFormedUriString(normalizedApiTokenIssuer)) { From b75aa51cd1d3c85b249ef2db12bf8496ddc1dfc4 Mon Sep 17 00:00:00 2001 From: SoulPancake Date: Mon, 18 May 2026 14:00:53 +0530 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20test=20comment=C3=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ApiClient/OAuth2ClientTests.cs | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/OpenFga.Sdk.Test/ApiClient/OAuth2ClientTests.cs b/src/OpenFga.Sdk.Test/ApiClient/OAuth2ClientTests.cs index 003736ed..3da3574d 100644 --- a/src/OpenFga.Sdk.Test/ApiClient/OAuth2ClientTests.cs +++ b/src/OpenFga.Sdk.Test/ApiClient/OAuth2ClientTests.cs @@ -331,8 +331,10 @@ public async Task OAuth2_ExchangeToken_IncludesScopesInRequest() { "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .ReturnsAsync((HttpRequestMessage request, CancellationToken ct) => { - requestBody = request.Content?.ReadAsStringAsync().Result; + .Returns(async (HttpRequestMessage request, CancellationToken ct) => { + requestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(); return CreateTokenResponse(); }); @@ -361,8 +363,10 @@ public async Task OAuth2_ExchangeToken_OmitsScopeWhenNotConfigured() { "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .ReturnsAsync((HttpRequestMessage request, CancellationToken ct) => { - requestBody = request.Content?.ReadAsStringAsync().Result; + .Returns(async (HttpRequestMessage request, CancellationToken ct) => { + requestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(); return CreateTokenResponse(); }); @@ -399,8 +403,10 @@ public async Task OAuth2_ExchangeToken_WorksWithoutAudience() { "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .ReturnsAsync((HttpRequestMessage request, CancellationToken ct) => { - requestBody = request.Content?.ReadAsStringAsync().Result; + .Returns(async (HttpRequestMessage request, CancellationToken ct) => { + requestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(); return CreateTokenResponse(); }); @@ -440,8 +446,10 @@ public async Task OAuth2_ExchangeToken_IncludesBothAudienceAndScopes() { "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .ReturnsAsync((HttpRequestMessage request, CancellationToken ct) => { - requestBody = request.Content?.ReadAsStringAsync().Result; + .Returns(async (HttpRequestMessage request, CancellationToken ct) => { + requestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(); return CreateTokenResponse(); });