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..3da3574d 100644 --- a/src/OpenFga.Sdk.Test/ApiClient/OAuth2ClientTests.cs +++ b/src/OpenFga.Sdk.Test/ApiClient/OAuth2ClientTests.cs @@ -307,6 +307,167 @@ 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()) + .Returns(async (HttpRequestMessage request, CancellationToken ct) => { + requestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(); + 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()) + .Returns(async (HttpRequestMessage request, CancellationToken ct) => { + requestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(); + 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()) + .Returns(async (HttpRequestMessage request, CancellationToken ct) => { + requestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(); + 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()) + .Returns(async (HttpRequestMessage request, CancellationToken ct) => { + requestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(); + 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)) {