Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -282,12 +283,8 @@ void ActionMissingApiTokenIssuer() =>
}
};

void ActionMissingApiAudience() =>
missingApiAudienceConfig.EnsureValid();
var exceptionMissingApiAudience =
Assert.Throws<FgaRequiredParamError>(ActionMissingApiAudience);
Assert.Equal("Required parameter ApiAudience was not defined when calling Configuration.",
exceptionMissingApiAudience.Message);
var exception = Record.Exception(() => missingApiAudienceConfig.EnsureValid());
Assert.Null(exception);
}

/// <summary>
Expand Down
161 changes: 161 additions & 0 deletions src/OpenFga.Sdk.Test/ApiClient/OAuth2ClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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);
Comment thread
rhamzeh marked this conversation as resolved.
}

[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<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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]
Expand Down
6 changes: 5 additions & 1 deletion src/OpenFga.Sdk/ApiClient/OAuth2Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
15 changes: 11 additions & 4 deletions src/OpenFga.Sdk/Configuration/Credentials.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,19 @@ public interface IClientCredentialsConfig {

/// <summary>
/// Gets or sets the API Audience.
/// Optional — only required for Auth0-style token servers.
/// Standard OAuth2 servers typically do not use this parameter.
/// </summary>
/// <value>API Audience.</value>
string? ApiAudience { get; set; }

/// <summary>
/// Gets or sets the OAuth2 scopes to request during the client credentials token exchange.
/// Space-separated string of scopes (e.g. "scope1 scope2").
/// Optional.
/// </summary>
/// <value>OAuth2 Scopes.</value>
string? Scopes { get; set; }
Comment thread
SoulPancake marked this conversation as resolved.
Comment thread
SoulPancake marked this conversation as resolved.
}

public interface ICredentialsConfig : IClientCredentialsConfig, IApiTokenConfig { }
Expand All @@ -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; }
}

Expand Down Expand Up @@ -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)) {
Expand Down
Loading