From a72fed5b199d5072a537cfb7ccddc15fa27ca7ce Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Sat, 25 Apr 2026 10:45:50 -0300 Subject: [PATCH 1/9] fix(#25): include client allowed audiences in user access token generation --- .../AuthorizationCodeGrantHandler.cs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/AuthorizationCodeGrantHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/AuthorizationCodeGrantHandler.cs index 65cf56b..6d055c6 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/AuthorizationCodeGrantHandler.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/AuthorizationCodeGrantHandler.cs @@ -1,6 +1,6 @@ namespace HttpsRichardy.Federation.Application.Handlers.Authorization; -public sealed class AuthorizationCodeGrantHandler(IRealmCollection realmCollection, IUserCollection userCollection, ISecurityTokenService tokenService, ITokenCollection tokenCollection) : +public sealed class AuthorizationCodeGrantHandler(IRealmCollection realmCollection, IUserCollection userCollection, IClientCollection clientCollection, ISecurityTokenService tokenService, ITokenCollection tokenCollection) : IAuthorizationFlowHandler { public Grant Grant => Grant.AuthorizationCode; @@ -38,6 +38,24 @@ public async Task> HandleAsync( return Result.Failure(AuthenticationErrors.ClientNotFound); } + var clientFilters = new ClientFiltersBuilder() + .WithClientId(parameters.ClientId) + .Build(); + + var clients = await clientCollection.GetClientsAsync(clientFilters, cancellation: cancellation); + var client = clients.FirstOrDefault(); + + if (client is null || !string.Equals(client.RealmId, token.RealmId, StringComparison.Ordinal)) + { + return Result.Failure(AuthorizationErrors.InvalidAuthorizationCode); + } + + var boundClientId = token.Metadata.GetValueOrDefault("client.id"); + if (!string.Equals(boundClientId, parameters.ClientId, StringComparison.Ordinal)) + { + return Result.Failure(AuthorizationErrors.InvalidAuthorizationCode); + } + var codeChallenge = token.Metadata.GetValueOrDefault("code.challenge")!; var codeChallengeMethod = token.Metadata.GetValueOrDefault("code.challenge.method")!; @@ -58,7 +76,7 @@ public async Task> HandleAsync( return Result.Failure(AuthenticationErrors.UserNotFound); } - var tokenResult = await tokenService.GenerateAccessTokenAsync(user, cancellation); + var tokenResult = await tokenService.GenerateAccessTokenAsync(user, client.Audiences, cancellation); if (tokenResult.IsFailure || tokenResult.Data is null) { return Result.Failure(tokenResult.Error); From 234734befaf7a9a2dc50dfb169a32b82ff7671c2 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Sat, 25 Apr 2026 10:46:34 -0300 Subject: [PATCH 2/9] fix(#25): this commit introduces overload for "generate access token async" to include audiences parameter --- .../Services/ISecurityTokenService.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecurityTokenService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecurityTokenService.cs index d996bfc..f4670ff 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecurityTokenService.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecurityTokenService.cs @@ -7,6 +7,12 @@ public Task> GenerateAccessTokenAsync( CancellationToken cancellation = default ); + public Task> GenerateAccessTokenAsync( + User user, + IEnumerable audiences, + CancellationToken cancellation = default + ); + public Task> GenerateAccessTokenAsync( Client client, CancellationToken cancellation = default @@ -31,4 +37,4 @@ public Task RevokeRefreshTokenAsync( SecurityToken token, CancellationToken cancellation = default ); -} \ No newline at end of file +} From 9180a6de1113c26102d85e355adae276c5dc733d Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Sat, 25 Apr 2026 10:47:06 -0300 Subject: [PATCH 3/9] fix(#25): this commit introduces validation for client identifier in authorization code grant type --- .../Identity/ClientAuthenticationCredentialsValidator.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Identity/ClientAuthenticationCredentialsValidator.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Identity/ClientAuthenticationCredentialsValidator.cs index e72ce9b..8284c5f 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Identity/ClientAuthenticationCredentialsValidator.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Identity/ClientAuthenticationCredentialsValidator.cs @@ -27,6 +27,12 @@ public ClientAuthenticationCredentialsValidator() When(credential => credential.GrantType == SupportedGrantType.AuthorizationCode, () => { + RuleFor(credential => credential.ClientId) + .NotEmpty() + .WithMessage("client identifier must not be empty.") + .MaximumLength(200) + .WithMessage("client identifier must be at most 200 characters long."); + RuleFor(credential => credential.Code) .NotEmpty() .WithMessage("code must not be empty."); From e45aca89b1a8af49200f5e3655e2346bab9bc669 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Sat, 25 Apr 2026 10:47:26 -0300 Subject: [PATCH 4/9] fix(#25): include missing domain concepts in usings.cs --- .../Source/HttpsRichardy.Federation.Infrastructure/Usings.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Usings.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Usings.cs index 0e3248b..919f944 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Usings.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Usings.cs @@ -10,6 +10,7 @@ global using HttpsRichardy.Federation.Domain.Aggregates; global using HttpsRichardy.Federation.Domain.Errors; +global using HttpsRichardy.Federation.Domain.Concepts; global using HttpsRichardy.Federation.Domain.Collections; global using HttpsRichardy.Federation.Domain.Filtering; global using HttpsRichardy.Federation.Domain.Filtering.Builders; From de1be8279c75ab67c16335a3e42e9658d3485c9c Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Sat, 25 Apr 2026 10:47:51 -0300 Subject: [PATCH 5/9] fix(#25): include client ID in metadata for access token generation --- .../HttpsRichardy.Federation.WebApi/Pages/Authorize.cshtml.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Pages/Authorize.cshtml.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Pages/Authorize.cshtml.cs index 25ea918..341f334 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Pages/Authorize.cshtml.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Pages/Authorize.cshtml.cs @@ -121,6 +121,7 @@ public async Task OnPostAsync() var code = Guid.NewGuid().ToString("N").ToUpperInvariant(); var metadata = new Dictionary { + { "client.id", Parameters.ClientId ?? string.Empty }, { "code.challenge", Parameters.CodeChallenge ?? string.Empty }, { "code.challenge.method", Parameters.CodeChallengeMethod ?? string.Empty } }; From 9a12a0323ccd0c7f39ad60ce4805b03606f999f3 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Sat, 25 Apr 2026 10:48:52 -0300 Subject: [PATCH 6/9] fix(#25): this commit updates method to include audiences parameter for improved token generation --- .../Security/JwtSecurityTokenService.cs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs index 0e7450e..a7d4139 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs @@ -12,7 +12,7 @@ IHostInformationProvider host private readonly TimeSpan _accessTokenDuration = TimeSpan.FromHours(2); private readonly TimeSpan _refreshTokenDuration = TimeSpan.FromDays(7); - public async Task> GenerateAccessTokenAsync(User user, CancellationToken cancellation = default) + public async Task> GenerateAccessTokenAsync(User user, IEnumerable audiences, CancellationToken cancellation = default) { var filters = GroupFilters.WithSpecifications() .WithRealmId(user.RealmId) @@ -31,6 +31,12 @@ public async Task> GenerateAccessTokenAsync(User user, Can .ToList(); var tokenHandler = new JwtSecurityTokenHandler(); + var resolvedAudiences = audiences + .Where(audience => !string.IsNullOrWhiteSpace(audience.Value)) + .Select(audience => audience.Value.Trim()) + .Distinct(StringComparer.Ordinal) + .ToList(); + var claims = new ClaimsBuilder() .WithSubject(user.Id.ToString()) .WithUsername(user.Username) @@ -43,10 +49,15 @@ public async Task> GenerateAccessTokenAsync(User user, Can claims.WithClaim(IdentityClaimNames.Realm, realm.Name); claims.WithClaim(IdentityClaimNames.RealmId, realm.Id); + if (resolvedAudiences.Count > 0) + { + claims.WithAudiences(resolvedAudiences); + } + var claimsIdentity = new ClaimsIdentity(claims.Build()); var tokenDescriptor = new SecurityTokenDescriptor { - Audience = realm.Name, + Audience = resolvedAudiences.Count > 0 ? null : realm.Name, Subject = claimsIdentity, Issuer = host.Address.ToString().TrimEnd('/'), SigningCredentials = credentials, @@ -66,6 +77,9 @@ public async Task> GenerateAccessTokenAsync(User user, Can return Result.Success(securityToken); } + public async Task> GenerateAccessTokenAsync(User user, CancellationToken cancellation = default) + => await GenerateAccessTokenAsync(user, [], cancellation); + public async Task> GenerateAccessTokenAsync(Client client, CancellationToken cancellation = default) { var tokenHandler = new JwtSecurityTokenHandler(); From 7651caef76cd60dbd316657dcf862d2051ab3890 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Sat, 25 Apr 2026 10:49:34 -0300 Subject: [PATCH 7/9] fix(#25): this commit introduces audience assignment for client and validate in access token --- .../Endpoints/ConnectEndpointTests.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Applications/Backend/Tests/Integration/Endpoints/ConnectEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/ConnectEndpointTests.cs index e9e1e9f..0a47612 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/ConnectEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/ConnectEndpointTests.cs @@ -284,6 +284,12 @@ public async Task WhenPostTokenWithValidAuthorizationCode_ShouldReturnAccessToke Assert.NotEmpty(clients); Assert.NotNull(client); + // arrange: assign client audience + var assignAudience = new AssignClientAudienceScheme { Value = "backend.api" }; + var assignAudienceResponse = await realmAdminClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/audiences", assignAudience); + + Assert.Equal(HttpStatusCode.OK, assignAudienceResponse.StatusCode); + // arrange: create user for realm var credentials = new IdentityEnrollmentCredentials { @@ -341,6 +347,7 @@ public async Task WhenPostTokenWithValidAuthorizationCode_ShouldReturnAccessToke ExpiresAt = DateTime.UtcNow.AddMinutes(5), Metadata = new Dictionary { + ["client.id"] = client.ClientId, ["code.challenge"] = codeChallenge, ["code.challenge.method"] = codeChallengeMethod } @@ -367,5 +374,15 @@ public async Task WhenPostTokenWithValidAuthorizationCode_ShouldReturnAccessToke // assert: response should be 200 OK Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(grant); + + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(grant.AccessToken); + var audiences = jwt.Claims + .Where(claim => claim.Type == JwtRegisteredClaimNames.Aud) + .Select(claim => claim.Value) + .ToList(); + + Assert.Contains("backend.api", audiences); + Assert.DoesNotContain(realm.Name, audiences); } } From d8eca4d8750561b14b30ffc50963fd02695ceaa8 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Sat, 25 Apr 2026 10:50:00 -0300 Subject: [PATCH 8/9] fix(#25): this commit introduces test for user access token generation with specified audiences --- .../Security/JwtSecurityTokenServiceTests.cs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/Applications/Backend/Tests/Integration/Security/JwtSecurityTokenServiceTests.cs b/Applications/Backend/Tests/Integration/Security/JwtSecurityTokenServiceTests.cs index cc75eee..0e15244 100644 --- a/Applications/Backend/Tests/Integration/Security/JwtSecurityTokenServiceTests.cs +++ b/Applications/Backend/Tests/Integration/Security/JwtSecurityTokenServiceTests.cs @@ -98,6 +98,43 @@ public async Task WhenGeneratingAccessToken_ThenItMustBeValidAndContainCorrectCl } } + [Fact(DisplayName = "[infrastructure] - when generating user access token with provided audiences, then token should include only provided audiences")] + public async Task WhenGeneratingUserAccessTokenWithProvidedAudiences_ThenShouldIncludeOnlyProvidedAudiences() + { + /* arrange: create a user and configure realm */ + var user = _fixture.Create(); + var realm = _fixture.Create(); + + _realmProvider.Setup(provider => provider.GetCurrentRealm()) + .Returns(realm); + + var allowedAudiences = new[] + { + new Audience("backend.api"), + new Audience("orders.api"), + new Audience("backend.api") + }; + + /* act: generate an access token with explicit audiences */ + var result = await _jwtSecurityTokenService.GenerateAccessTokenAsync(user, allowedAudiences); + + /* assert: token must be successful and valid */ + Assert.True(result.IsSuccess); + Assert.NotNull(result.Data); + + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(result.Data.Value); + + var audiences = jwtToken.Claims + .Where(claim => claim.Type == JwtRegisteredClaimNames.Aud) + .Select(claim => claim.Value) + .ToList(); + + Assert.Contains("backend.api", audiences); + Assert.Contains("orders.api", audiences); + Assert.Equal(2, audiences.Distinct(StringComparer.Ordinal).Count()); + } + [Fact(DisplayName = "[infrastructure] - when generating a refresh token, then it must be valid and contain correct claims and be persisted")] public async Task WhenGeneratingRefreshToken_ThenItMustBeValidAndContainCorrectClaimsAndBePersisted() { From bf5ee3331e323cc72ecbdc8faa049dd3da3e9823 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Sat, 25 Apr 2026 10:57:11 -0300 Subject: [PATCH 9/9] docs(#25): this commit updates CHANGELOG for 4.2.1 release and README to reflect new version --- CHANGELOG | 6 ++++++ README.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 8ad7812..fcb115a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,9 @@ +# 4.2.1 - 2026-04-25 + +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. + +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. + # 4.2.0 - 2026-04-24 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. diff --git a/README.md b/README.md index 4ecde06..f1494cc 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ You can pull either: ```bash docker pull httpsrichardy/federation:latest -docker pull httpsrichardy/federation:4.1.0 +docker pull httpsrichardy/federation:4.2.1 ``` To run the container, provide the required environment variables for database and administration bootstrap: