Skip to content

Commit 992491a

Browse files
merge pull request #26 from https-richardy/fix/25-include-client-allowed-audiences-in-user-access-token
[#25] - include client allowed audiences in user access token
2 parents ce16353 + bf5ee33 commit 992491a

10 files changed

Lines changed: 112 additions & 6 deletions

File tree

Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/AuthorizationCodeGrantHandler.cs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
namespace HttpsRichardy.Federation.Application.Handlers.Authorization;
22

3-
public sealed class AuthorizationCodeGrantHandler(IRealmCollection realmCollection, IUserCollection userCollection, ISecurityTokenService tokenService, ITokenCollection tokenCollection) :
3+
public sealed class AuthorizationCodeGrantHandler(IRealmCollection realmCollection, IUserCollection userCollection, IClientCollection clientCollection, ISecurityTokenService tokenService, ITokenCollection tokenCollection) :
44
IAuthorizationFlowHandler
55
{
66
public Grant Grant => Grant.AuthorizationCode;
@@ -38,6 +38,24 @@ public async Task<Result<ClientAuthenticationResult>> HandleAsync(
3838
return Result<ClientAuthenticationResult>.Failure(AuthenticationErrors.ClientNotFound);
3939
}
4040

41+
var clientFilters = new ClientFiltersBuilder()
42+
.WithClientId(parameters.ClientId)
43+
.Build();
44+
45+
var clients = await clientCollection.GetClientsAsync(clientFilters, cancellation: cancellation);
46+
var client = clients.FirstOrDefault();
47+
48+
if (client is null || !string.Equals(client.RealmId, token.RealmId, StringComparison.Ordinal))
49+
{
50+
return Result<ClientAuthenticationResult>.Failure(AuthorizationErrors.InvalidAuthorizationCode);
51+
}
52+
53+
var boundClientId = token.Metadata.GetValueOrDefault("client.id");
54+
if (!string.Equals(boundClientId, parameters.ClientId, StringComparison.Ordinal))
55+
{
56+
return Result<ClientAuthenticationResult>.Failure(AuthorizationErrors.InvalidAuthorizationCode);
57+
}
58+
4159
var codeChallenge = token.Metadata.GetValueOrDefault("code.challenge")!;
4260
var codeChallengeMethod = token.Metadata.GetValueOrDefault("code.challenge.method")!;
4361

@@ -58,7 +76,7 @@ public async Task<Result<ClientAuthenticationResult>> HandleAsync(
5876
return Result<ClientAuthenticationResult>.Failure(AuthenticationErrors.UserNotFound);
5977
}
6078

61-
var tokenResult = await tokenService.GenerateAccessTokenAsync(user, cancellation);
79+
var tokenResult = await tokenService.GenerateAccessTokenAsync(user, client.Audiences, cancellation);
6280
if (tokenResult.IsFailure || tokenResult.Data is null)
6381
{
6482
return Result<ClientAuthenticationResult>.Failure(tokenResult.Error);

Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecurityTokenService.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ public Task<Result<SecurityToken>> GenerateAccessTokenAsync(
77
CancellationToken cancellation = default
88
);
99

10+
public Task<Result<SecurityToken>> GenerateAccessTokenAsync(
11+
User user,
12+
IEnumerable<Audience> audiences,
13+
CancellationToken cancellation = default
14+
);
15+
1016
public Task<Result<SecurityToken>> GenerateAccessTokenAsync(
1117
Client client,
1218
CancellationToken cancellation = default
@@ -31,4 +37,4 @@ public Task<Result> RevokeRefreshTokenAsync(
3137
SecurityToken token,
3238
CancellationToken cancellation = default
3339
);
34-
}
40+
}

Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Identity/ClientAuthenticationCredentialsValidator.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ public ClientAuthenticationCredentialsValidator()
2727

2828
When(credential => credential.GrantType == SupportedGrantType.AuthorizationCode, () =>
2929
{
30+
RuleFor(credential => credential.ClientId)
31+
.NotEmpty()
32+
.WithMessage("client identifier must not be empty.")
33+
.MaximumLength(200)
34+
.WithMessage("client identifier must be at most 200 characters long.");
35+
3036
RuleFor(credential => credential.Code)
3137
.NotEmpty()
3238
.WithMessage("code must not be empty.");

Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ IHostInformationProvider host
1212
private readonly TimeSpan _accessTokenDuration = TimeSpan.FromHours(2);
1313
private readonly TimeSpan _refreshTokenDuration = TimeSpan.FromDays(7);
1414

15-
public async Task<Result<SecurityToken>> GenerateAccessTokenAsync(User user, CancellationToken cancellation = default)
15+
public async Task<Result<SecurityToken>> GenerateAccessTokenAsync(User user, IEnumerable<Audience> audiences, CancellationToken cancellation = default)
1616
{
1717
var filters = GroupFilters.WithSpecifications()
1818
.WithRealmId(user.RealmId)
@@ -31,6 +31,12 @@ public async Task<Result<SecurityToken>> GenerateAccessTokenAsync(User user, Can
3131
.ToList();
3232

3333
var tokenHandler = new JwtSecurityTokenHandler();
34+
var resolvedAudiences = audiences
35+
.Where(audience => !string.IsNullOrWhiteSpace(audience.Value))
36+
.Select(audience => audience.Value.Trim())
37+
.Distinct(StringComparer.Ordinal)
38+
.ToList();
39+
3440
var claims = new ClaimsBuilder()
3541
.WithSubject(user.Id.ToString())
3642
.WithUsername(user.Username)
@@ -43,10 +49,15 @@ public async Task<Result<SecurityToken>> GenerateAccessTokenAsync(User user, Can
4349
claims.WithClaim(IdentityClaimNames.Realm, realm.Name);
4450
claims.WithClaim(IdentityClaimNames.RealmId, realm.Id);
4551

52+
if (resolvedAudiences.Count > 0)
53+
{
54+
claims.WithAudiences(resolvedAudiences);
55+
}
56+
4657
var claimsIdentity = new ClaimsIdentity(claims.Build());
4758
var tokenDescriptor = new SecurityTokenDescriptor
4859
{
49-
Audience = realm.Name,
60+
Audience = resolvedAudiences.Count > 0 ? null : realm.Name,
5061
Subject = claimsIdentity,
5162
Issuer = host.Address.ToString().TrimEnd('/'),
5263
SigningCredentials = credentials,
@@ -66,6 +77,9 @@ public async Task<Result<SecurityToken>> GenerateAccessTokenAsync(User user, Can
6677
return Result<SecurityToken>.Success(securityToken);
6778
}
6879

80+
public async Task<Result<SecurityToken>> GenerateAccessTokenAsync(User user, CancellationToken cancellation = default)
81+
=> await GenerateAccessTokenAsync(user, [], cancellation);
82+
6983
public async Task<Result<SecurityToken>> GenerateAccessTokenAsync(Client client, CancellationToken cancellation = default)
7084
{
7185
var tokenHandler = new JwtSecurityTokenHandler();

Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Usings.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
global using HttpsRichardy.Federation.Domain.Aggregates;
1212
global using HttpsRichardy.Federation.Domain.Errors;
13+
global using HttpsRichardy.Federation.Domain.Concepts;
1314
global using HttpsRichardy.Federation.Domain.Collections;
1415
global using HttpsRichardy.Federation.Domain.Filtering;
1516
global using HttpsRichardy.Federation.Domain.Filtering.Builders;

Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Pages/Authorize.cshtml.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ public async Task<IActionResult> OnPostAsync()
121121
var code = Guid.NewGuid().ToString("N").ToUpperInvariant();
122122
var metadata = new Dictionary<string, string>
123123
{
124+
{ "client.id", Parameters.ClientId ?? string.Empty },
124125
{ "code.challenge", Parameters.CodeChallenge ?? string.Empty },
125126
{ "code.challenge.method", Parameters.CodeChallengeMethod ?? string.Empty }
126127
};

Applications/Backend/Tests/Integration/Endpoints/ConnectEndpointTests.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,12 @@ public async Task WhenPostTokenWithValidAuthorizationCode_ShouldReturnAccessToke
284284
Assert.NotEmpty(clients);
285285
Assert.NotNull(client);
286286

287+
// arrange: assign client audience
288+
var assignAudience = new AssignClientAudienceScheme { Value = "backend.api" };
289+
var assignAudienceResponse = await realmAdminClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/audiences", assignAudience);
290+
291+
Assert.Equal(HttpStatusCode.OK, assignAudienceResponse.StatusCode);
292+
287293
// arrange: create user for realm
288294
var credentials = new IdentityEnrollmentCredentials
289295
{
@@ -341,6 +347,7 @@ public async Task WhenPostTokenWithValidAuthorizationCode_ShouldReturnAccessToke
341347
ExpiresAt = DateTime.UtcNow.AddMinutes(5),
342348
Metadata = new Dictionary<string, string>
343349
{
350+
["client.id"] = client.ClientId,
344351
["code.challenge"] = codeChallenge,
345352
["code.challenge.method"] = codeChallengeMethod
346353
}
@@ -367,5 +374,15 @@ public async Task WhenPostTokenWithValidAuthorizationCode_ShouldReturnAccessToke
367374
// assert: response should be 200 OK
368375
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
369376
Assert.NotNull(grant);
377+
378+
var handler = new JwtSecurityTokenHandler();
379+
var jwt = handler.ReadJwtToken(grant.AccessToken);
380+
var audiences = jwt.Claims
381+
.Where(claim => claim.Type == JwtRegisteredClaimNames.Aud)
382+
.Select(claim => claim.Value)
383+
.ToList();
384+
385+
Assert.Contains("backend.api", audiences);
386+
Assert.DoesNotContain(realm.Name, audiences);
370387
}
371388
}

Applications/Backend/Tests/Integration/Security/JwtSecurityTokenServiceTests.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,43 @@ public async Task WhenGeneratingAccessToken_ThenItMustBeValidAndContainCorrectCl
9898
}
9999
}
100100

101+
[Fact(DisplayName = "[infrastructure] - when generating user access token with provided audiences, then token should include only provided audiences")]
102+
public async Task WhenGeneratingUserAccessTokenWithProvidedAudiences_ThenShouldIncludeOnlyProvidedAudiences()
103+
{
104+
/* arrange: create a user and configure realm */
105+
var user = _fixture.Create<User>();
106+
var realm = _fixture.Create<Realm>();
107+
108+
_realmProvider.Setup(provider => provider.GetCurrentRealm())
109+
.Returns(realm);
110+
111+
var allowedAudiences = new[]
112+
{
113+
new Audience("backend.api"),
114+
new Audience("orders.api"),
115+
new Audience("backend.api")
116+
};
117+
118+
/* act: generate an access token with explicit audiences */
119+
var result = await _jwtSecurityTokenService.GenerateAccessTokenAsync(user, allowedAudiences);
120+
121+
/* assert: token must be successful and valid */
122+
Assert.True(result.IsSuccess);
123+
Assert.NotNull(result.Data);
124+
125+
var handler = new JwtSecurityTokenHandler();
126+
var jwtToken = handler.ReadJwtToken(result.Data.Value);
127+
128+
var audiences = jwtToken.Claims
129+
.Where(claim => claim.Type == JwtRegisteredClaimNames.Aud)
130+
.Select(claim => claim.Value)
131+
.ToList();
132+
133+
Assert.Contains("backend.api", audiences);
134+
Assert.Contains("orders.api", audiences);
135+
Assert.Equal(2, audiences.Distinct(StringComparer.Ordinal).Count());
136+
}
137+
101138
[Fact(DisplayName = "[infrastructure] - when generating a refresh token, then it must be valid and contain correct claims and be persisted")]
102139
public async Task WhenGeneratingRefreshToken_ThenItMustBeValidAndContainCorrectClaimsAndBePersisted()
103140
{

CHANGELOG

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
# 4.2.1 - 2026-04-25
2+
3+
this patch fixes an issue in the authorization_code flow where the access token issued for an authenticated user did not include the allowed audiences configured on the requesting client. this created a mismatch between the client context that initiated authorization and the resulting user token.
4+
5+
starting in 4.2.1, when a client obtains an authorization code and exchanges it for an access token, the generated user access token now includes all allowed audiences configured for that client. in the same flow, the authorization code is also bound to the client context to ensure exchange consistency.
6+
17
# 4.2.0 - 2026-04-24
28

39
this release introduces a fluent builder api to the sdk, making it more intuitive and expressive to construct filter parameters for client calls. previously, using parameter models required manual object initialization and explicit property assignment, which could become verbose as the number of filters grew. with the new fluent approach, developers can chain builder methods in a readable and intention-driven way, improving both usability and discoverability of the api.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ You can pull either:
6767

6868
```bash
6969
docker pull httpsrichardy/federation:latest
70-
docker pull httpsrichardy/federation:4.1.0
70+
docker pull httpsrichardy/federation:4.2.1
7171
```
7272

7373
To run the container, provide the required environment variables for database and administration bootstrap:

0 commit comments

Comments
 (0)