Skip to content

Commit ff9fcff

Browse files
authored
feat: support OAuth2 scopes for client credentials authentication (#218)
* 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 * fix: address test commentæ
1 parent 22a65fd commit ff9fcff

4 files changed

Lines changed: 180 additions & 11 deletions

File tree

src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ void ActionMissingApiTokenIssuer() =>
269269
Assert.Equal("Required parameter ApiTokenIssuer was not defined when calling Configuration.",
270270
exceptionMissingApiTokenIssuer.Message);
271271

272+
// ApiAudience is optional — standard OAuth2 servers don't require it
272273
var missingApiAudienceConfig = new SdkConfiguration {
273274
StoreId = _storeId,
274275
ApiHost = _host,
@@ -282,12 +283,8 @@ void ActionMissingApiTokenIssuer() =>
282283
}
283284
};
284285

285-
void ActionMissingApiAudience() =>
286-
missingApiAudienceConfig.EnsureValid();
287-
var exceptionMissingApiAudience =
288-
Assert.Throws<FgaRequiredParamError>(ActionMissingApiAudience);
289-
Assert.Equal("Required parameter ApiAudience was not defined when calling Configuration.",
290-
exceptionMissingApiAudience.Message);
286+
var exception = Record.Exception(() => missingApiAudienceConfig.EnsureValid());
287+
Assert.Null(exception);
291288
}
292289

293290
/// <summary>

src/OpenFga.Sdk.Test/ApiClient/OAuth2ClientTests.cs

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,167 @@ public async Task OAuth2_ExchangeToken_RetriesOnNetworkError() {
307307

308308
#endregion
309309

310+
#region OAuth2 Scopes
311+
312+
[Fact]
313+
public async Task OAuth2_ExchangeToken_IncludesScopesInRequest() {
314+
var credentials = new Credentials {
315+
Method = CredentialsMethod.ClientCredentials,
316+
Config = new CredentialsConfig {
317+
ClientId = TestClientId,
318+
ClientSecret = TestClientSecret,
319+
ApiTokenIssuer = TestTokenIssuer,
320+
ApiAudience = TestAudience,
321+
Scopes = "scope1 scope2"
322+
}
323+
};
324+
var retryParams = CreateTestRetryParams(maxRetry: 0, minWaitInMs: 10);
325+
326+
string? requestBody = null;
327+
var mockHandler = new Mock<HttpMessageHandler>();
328+
mockHandler
329+
.Protected()
330+
.Setup<Task<HttpResponseMessage>>(
331+
"SendAsync",
332+
ItExpr.IsAny<HttpRequestMessage>(),
333+
ItExpr.IsAny<CancellationToken>())
334+
.Returns(async (HttpRequestMessage request, CancellationToken ct) => {
335+
requestBody = request.Content is null
336+
? null
337+
: await request.Content.ReadAsStringAsync();
338+
return CreateTokenResponse();
339+
});
340+
341+
var httpClient = new HttpClient(mockHandler.Object);
342+
var configuration = new SdkConfiguration { ApiUrl = Constants.FgaConstants.TestApiUrl };
343+
var baseClient = new BaseClient(configuration, httpClient);
344+
var metrics = new Metrics(configuration);
345+
346+
var oauth2Client = new OAuth2Client(credentials, baseClient, retryParams, metrics);
347+
await oauth2Client.GetAccessTokenAsync();
348+
349+
Assert.NotNull(requestBody);
350+
Assert.Contains("scope=scope1+scope2", requestBody);
351+
}
352+
353+
[Fact]
354+
public async Task OAuth2_ExchangeToken_OmitsScopeWhenNotConfigured() {
355+
var credentials = CreateTestCredentials();
356+
var retryParams = CreateTestRetryParams(maxRetry: 0, minWaitInMs: 10);
357+
358+
string? requestBody = null;
359+
var mockHandler = new Mock<HttpMessageHandler>();
360+
mockHandler
361+
.Protected()
362+
.Setup<Task<HttpResponseMessage>>(
363+
"SendAsync",
364+
ItExpr.IsAny<HttpRequestMessage>(),
365+
ItExpr.IsAny<CancellationToken>())
366+
.Returns(async (HttpRequestMessage request, CancellationToken ct) => {
367+
requestBody = request.Content is null
368+
? null
369+
: await request.Content.ReadAsStringAsync();
370+
return CreateTokenResponse();
371+
});
372+
373+
var httpClient = new HttpClient(mockHandler.Object);
374+
var configuration = new SdkConfiguration { ApiUrl = Constants.FgaConstants.TestApiUrl };
375+
var baseClient = new BaseClient(configuration, httpClient);
376+
var metrics = new Metrics(configuration);
377+
378+
var oauth2Client = new OAuth2Client(credentials, baseClient, retryParams, metrics);
379+
await oauth2Client.GetAccessTokenAsync();
380+
381+
Assert.NotNull(requestBody);
382+
Assert.DoesNotContain("scope=", requestBody);
383+
}
384+
385+
[Fact]
386+
public async Task OAuth2_ExchangeToken_WorksWithoutAudience() {
387+
var credentials = new Credentials {
388+
Method = CredentialsMethod.ClientCredentials,
389+
Config = new CredentialsConfig {
390+
ClientId = TestClientId,
391+
ClientSecret = TestClientSecret,
392+
ApiTokenIssuer = TestTokenIssuer,
393+
Scopes = "read write"
394+
}
395+
};
396+
var retryParams = CreateTestRetryParams(maxRetry: 0, minWaitInMs: 10);
397+
398+
string? requestBody = null;
399+
var mockHandler = new Mock<HttpMessageHandler>();
400+
mockHandler
401+
.Protected()
402+
.Setup<Task<HttpResponseMessage>>(
403+
"SendAsync",
404+
ItExpr.IsAny<HttpRequestMessage>(),
405+
ItExpr.IsAny<CancellationToken>())
406+
.Returns(async (HttpRequestMessage request, CancellationToken ct) => {
407+
requestBody = request.Content is null
408+
? null
409+
: await request.Content.ReadAsStringAsync();
410+
return CreateTokenResponse();
411+
});
412+
413+
var httpClient = new HttpClient(mockHandler.Object);
414+
var configuration = new SdkConfiguration { ApiUrl = Constants.FgaConstants.TestApiUrl };
415+
var baseClient = new BaseClient(configuration, httpClient);
416+
var metrics = new Metrics(configuration);
417+
418+
var oauth2Client = new OAuth2Client(credentials, baseClient, retryParams, metrics);
419+
var token = await oauth2Client.GetAccessTokenAsync();
420+
421+
Assert.Equal("test-access-token", token);
422+
Assert.NotNull(requestBody);
423+
Assert.DoesNotContain("audience=", requestBody);
424+
Assert.Contains("scope=read+write", requestBody);
425+
}
426+
427+
[Fact]
428+
public async Task OAuth2_ExchangeToken_IncludesBothAudienceAndScopes() {
429+
var credentials = new Credentials {
430+
Method = CredentialsMethod.ClientCredentials,
431+
Config = new CredentialsConfig {
432+
ClientId = TestClientId,
433+
ClientSecret = TestClientSecret,
434+
ApiTokenIssuer = TestTokenIssuer,
435+
ApiAudience = TestAudience,
436+
Scopes = "openid profile"
437+
}
438+
};
439+
var retryParams = CreateTestRetryParams(maxRetry: 0, minWaitInMs: 10);
440+
441+
string? requestBody = null;
442+
var mockHandler = new Mock<HttpMessageHandler>();
443+
mockHandler
444+
.Protected()
445+
.Setup<Task<HttpResponseMessage>>(
446+
"SendAsync",
447+
ItExpr.IsAny<HttpRequestMessage>(),
448+
ItExpr.IsAny<CancellationToken>())
449+
.Returns(async (HttpRequestMessage request, CancellationToken ct) => {
450+
requestBody = request.Content is null
451+
? null
452+
: await request.Content.ReadAsStringAsync();
453+
return CreateTokenResponse();
454+
});
455+
456+
var httpClient = new HttpClient(mockHandler.Object);
457+
var configuration = new SdkConfiguration { ApiUrl = Constants.FgaConstants.TestApiUrl };
458+
var baseClient = new BaseClient(configuration, httpClient);
459+
var metrics = new Metrics(configuration);
460+
461+
var oauth2Client = new OAuth2Client(credentials, baseClient, retryParams, metrics);
462+
await oauth2Client.GetAccessTokenAsync();
463+
464+
Assert.NotNull(requestBody);
465+
Assert.Contains("audience=test-audience", requestBody);
466+
Assert.Contains("scope=openid+profile", requestBody);
467+
}
468+
469+
#endregion
470+
310471
#region ApiTokenIssuer Path Handling
311472

312473
[Theory]

src/OpenFga.Sdk/ApiClient/OAuth2Client.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,14 @@ public OAuth2Client(Credentials credentialsConfig, BaseClient httpClient, RetryP
131131
{ "grant_type", "client_credentials" }
132132
};
133133

134-
if (credentialsConfig.Config.ApiAudience != null) {
134+
if (!string.IsNullOrWhiteSpace(credentialsConfig.Config.ApiAudience)) {
135135
_authRequest["audience"] = credentialsConfig.Config.ApiAudience;
136136
}
137137

138+
if (!string.IsNullOrWhiteSpace(credentialsConfig.Config.Scopes)) {
139+
_authRequest["scope"] = credentialsConfig.Config.Scopes;
140+
}
141+
138142
_retryHandler = new RetryHandler(retryParams);
139143
this.metrics = metrics;
140144
}

src/OpenFga.Sdk/Configuration/Credentials.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,19 @@ public interface IClientCredentialsConfig {
7171

7272
/// <summary>
7373
/// Gets or sets the API Audience.
74+
/// Optional — only required for Auth0-style token servers.
75+
/// Standard OAuth2 servers typically do not use this parameter.
7476
/// </summary>
7577
/// <value>API Audience.</value>
7678
string? ApiAudience { get; set; }
79+
80+
/// <summary>
81+
/// Gets or sets the OAuth2 scopes to request during the client credentials token exchange.
82+
/// Space-separated string of scopes (e.g. "scope1 scope2").
83+
/// Optional.
84+
/// </summary>
85+
/// <value>OAuth2 Scopes.</value>
86+
string? Scopes { get; set; }
7787
}
7888

7989
public interface ICredentialsConfig : IClientCredentialsConfig, IApiTokenConfig { }
@@ -83,6 +93,7 @@ public struct CredentialsConfig : ICredentialsConfig {
8393
public string? ClientSecret { get; set; }
8494
public string? ApiTokenIssuer { get; set; }
8595
public string? ApiAudience { get; set; }
96+
public string? Scopes { get; set; }
8697
public string? ApiToken { get; set; }
8798
}
8899

@@ -140,10 +151,6 @@ public void EnsureValid() {
140151
throw new FgaRequiredParamError("Configuration", nameof(Config.ApiTokenIssuer));
141152
}
142153

143-
if (string.IsNullOrWhiteSpace(Config?.ApiAudience)) {
144-
throw new FgaRequiredParamError("Configuration", nameof(Config.ApiAudience));
145-
}
146-
147154
// Validate that the normalized URL is well-formed
148155
var normalizedApiTokenIssuer = ApiTokenIssuerNormalizer.Normalize(Config?.ApiTokenIssuer);
149156
if (!string.IsNullOrWhiteSpace(normalizedApiTokenIssuer) && !IsWellFormedUriString(normalizedApiTokenIssuer)) {

0 commit comments

Comments
 (0)