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
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -38,6 +38,24 @@ public async Task<Result<ClientAuthenticationResult>> HandleAsync(
return Result<ClientAuthenticationResult>.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<ClientAuthenticationResult>.Failure(AuthorizationErrors.InvalidAuthorizationCode);
}

var boundClientId = token.Metadata.GetValueOrDefault("client.id");
if (!string.Equals(boundClientId, parameters.ClientId, StringComparison.Ordinal))
{
return Result<ClientAuthenticationResult>.Failure(AuthorizationErrors.InvalidAuthorizationCode);
}

var codeChallenge = token.Metadata.GetValueOrDefault("code.challenge")!;
var codeChallengeMethod = token.Metadata.GetValueOrDefault("code.challenge.method")!;

Expand All @@ -58,7 +76,7 @@ public async Task<Result<ClientAuthenticationResult>> HandleAsync(
return Result<ClientAuthenticationResult>.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<ClientAuthenticationResult>.Failure(tokenResult.Error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ public Task<Result<SecurityToken>> GenerateAccessTokenAsync(
CancellationToken cancellation = default
);

public Task<Result<SecurityToken>> GenerateAccessTokenAsync(
User user,
IEnumerable<Audience> audiences,
CancellationToken cancellation = default
);

public Task<Result<SecurityToken>> GenerateAccessTokenAsync(
Client client,
CancellationToken cancellation = default
Expand All @@ -31,4 +37,4 @@ public Task<Result> RevokeRefreshTokenAsync(
SecurityToken token,
CancellationToken cancellation = default
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ IHostInformationProvider host
private readonly TimeSpan _accessTokenDuration = TimeSpan.FromHours(2);
private readonly TimeSpan _refreshTokenDuration = TimeSpan.FromDays(7);

public async Task<Result<SecurityToken>> GenerateAccessTokenAsync(User user, CancellationToken cancellation = default)
public async Task<Result<SecurityToken>> GenerateAccessTokenAsync(User user, IEnumerable<Audience> audiences, CancellationToken cancellation = default)
{
var filters = GroupFilters.WithSpecifications()
.WithRealmId(user.RealmId)
Expand All @@ -31,6 +31,12 @@ public async Task<Result<SecurityToken>> 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)
Expand All @@ -43,10 +49,15 @@ public async Task<Result<SecurityToken>> 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,
Expand All @@ -66,6 +77,9 @@ public async Task<Result<SecurityToken>> GenerateAccessTokenAsync(User user, Can
return Result<SecurityToken>.Success(securityToken);
}

public async Task<Result<SecurityToken>> GenerateAccessTokenAsync(User user, CancellationToken cancellation = default)
=> await GenerateAccessTokenAsync(user, [], cancellation);

public async Task<Result<SecurityToken>> GenerateAccessTokenAsync(Client client, CancellationToken cancellation = default)
{
var tokenHandler = new JwtSecurityTokenHandler();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ public async Task<IActionResult> OnPostAsync()
var code = Guid.NewGuid().ToString("N").ToUpperInvariant();
var metadata = new Dictionary<string, string>
{
{ "client.id", Parameters.ClientId ?? string.Empty },
{ "code.challenge", Parameters.CodeChallenge ?? string.Empty },
{ "code.challenge.method", Parameters.CodeChallengeMethod ?? string.Empty }
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -341,6 +347,7 @@ public async Task WhenPostTokenWithValidAuthorizationCode_ShouldReturnAccessToke
ExpiresAt = DateTime.UtcNow.AddMinutes(5),
Metadata = new Dictionary<string, string>
{
["client.id"] = client.ClientId,
["code.challenge"] = codeChallenge,
["code.challenge.method"] = codeChallengeMethod
}
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<User>();
var realm = _fixture.Create<Realm>();

_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()
{
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading