Search code examples
c#angularasp.net-corejwt

Angular 17 & dot net Core 8 - Jwt on refresh adding to Audience


I'm currently creating a simple web app which will create a Jwt with a Refresh Token (stored in the DB). No issues there, but when the refresh token is generated, it keeps appending to the Audience inside the Jwt and I'm unsure as to why. Creation and refresh are handled by dot net core 8, using Angular 17 as the front end.

Here is what I get after several token refreshes:

"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "[Removed]",
  "http://schemas.microsoft.com/ws/2008/06/identity/claims/role": "[Removed]",
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier": "[Removed]",
  "exp": 1718882692,
  "iss": "https://localhost:4200",
  "aud": [
    "https://localhost:4200",
    "https://localhost:4200",
    "https://localhost:4200",
    "https://localhost:4200",
    "https://localhost:4200",
    "https://localhost:4200",
    "https://localhost:4200",
    "https://localhost:4200",
    "https://localhost:4200"
  ]
}

I used this guide for adapting my code to create a refresh token. There are minor changes to the code as the user data stored in the Jwt is the Entra User ID value, not the DB User ID value.

I want to ensure the Issuer and Audience are validated, so I've made sure to include them and they are defined in appsettings.json (I've just used the URL of the address they're running on, either localhost or the final web domain).

Here is the applicable code showing the addition of the Audience into the Jwt and the refresh code and related models.

Program.cs

builder.Services.AddTransient<ITokenService, TokenService>();

var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
var jwtSettings = builder.Configuration.GetSection("JwtSettingsCommon");
var environment = builder.Configuration.GetSection("Production");
if (env == "Development")
{
    environment = builder.Configuration.GetSection("Development");
}
var JwtSecret = jwtSettings["SigningKey"];

if (JwtSecret != null)
{
    builder.Services.AddAuthentication(opt =>
    {
        opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = environment["URI"],
            ValidAudience = environment["URI"],
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret))
        };
    });
}

UserDataController.cs

[HttpPost("refresh")]
public async Task<IActionResult> Refresh(JwtToken tokenApiModel)
{
    if (tokenApiModel is null)
        return BadRequest(new { status = "error", message = "Invalid client request (JwtToken)" });
    string accessToken = tokenApiModel.Token;
    string refreshToken = tokenApiModel.RefreshToken;
    var principal = _tokenService.GetPrincipalFromExpiredToken(accessToken);
    var token = new JwtSecurityToken(accessToken);
    var userId = token.Claims.First(x => x.Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier").Value;
    var userQuery = from users in _dbContext.Set<UserData>()
                            join refreshTokens in _dbContext.Set<JwtRefreshTokens>()
                                on users.EntraUserID equals userId
                            select refreshTokens;
    var user = userQuery.FirstOrDefault();
    if (user is null || user.RefreshToken != refreshToken)
        return BadRequest(new { status = "error", message = "Invalid client request (checking Jwt refresh token in DB" });
    else if (user.RefreshTokenExpiryTime <= DateTime.Now)                
        return Ok(new { status = "error", message = "Refresh token expired" });
    var newAccessToken = _tokenService.GenerateAccessToken(principal.Claims);
    var newRefreshToken = _tokenService.GenerateRefreshToken();
    user.RefreshToken = newRefreshToken;
    user.RefreshTokenExpiryTime = DateTime.Now.AddDays(1);
    _dbContext.Update(user);
    await _dbContext.SaveChangesAsync();
    return Ok(new JwtToken()
    {
        Token = newAccessToken,
        RefreshToken = newRefreshToken
    }); 
}

auth.service.ts

async tryRefreshingTokens(token: string): Promise<boolean> {
  const refreshToken: string = localStorage.getItem("refreshToken");
  if (!token || !refreshToken) {
    console.log('No token or refreshToken')
    return false;
  }

  const credentials = JSON.stringify({ token: token, refreshToken: refreshToken });
  let isRefreshSuccess: boolean;
  const refreshRes = await new Promise<JwtToken>((resolve, reject) => {
    this.http.post<JwtToken>(`${environment.apiUrl}/userdata/refresh`, credentials, {
      headers: new HttpHeaders({
        "Content-Type": "application/json"
      })
    }).subscribe({
      next: (res: JwtToken) => resolve(res),
      error: (_) => { reject; isRefreshSuccess = false; }
    });
  });
  var validToken = this.jwtHelper.isTokenExpired();
  if (validToken) {      
    localStorage.setItem("jwt", refreshRes.token);
    localStorage.setItem("refreshToken", refreshRes.refreshToken);
    isRefreshSuccess = true;
  } else {
    isRefreshSuccess = false;
  }
  return isRefreshSuccess;
}

auth.guard.ts

export const canActivate: CanActivateFn = (
  route: ActivatedRouteSnapshot,
  state: RouterStateSnapshot
) => {
  const jwtHelper = inject(JwtHelperService);
  const authService =  inject(AuthService);
  const router = inject(Router)
  const token = localStorage.getItem("jwt");
  if (token && !jwtHelper.isTokenExpired(token)) {    
    return true;
  }
  const isRefreshSuccess = authService.tryRefreshingTokens(token);
  if (!isRefreshSuccess) {
    router.navigate(['/unauthorised']);
  }
  return isRefreshSuccess;
}

TokenService.cs

public class TokenService(IConfiguration configuration) : ITokenService
{
    private readonly IConfiguration _configuration = configuration;
    public string GenerateAccessToken(IEnumerable<Claim> claims)
    {
        var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
        var jwtSettings = _configuration.GetSection("JwtSettingsCommon");
        var environment = _configuration.GetSection("Production");
        if (env == "Development")
        {
            environment = _configuration.GetSection("Development");
        }
        var JwtSecret = jwtSettings["SigningKey"];
        var Issuer = environment["URI"];
        var Audience = environment["URI"];
        var secretKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret));
        var signinCredentials = new SigningCredentials(secretKey, SecurityAlgorithms.HmacSha256);

        int expiry;
        try
        {
            expiry = Int32.Parse(jwtSettings["Expires"]);
        }
        catch (FormatException)
        {
            expiry = 10;
        }
        var tokenOptions = new JwtSecurityToken(
            issuer: Issuer,
            audience: Audience,                
            claims: claims,
            expires: DateTime.Now.AddMinutes(expiry),
            signingCredentials: signinCredentials
        );
        var tokenString = new JwtSecurityTokenHandler().WriteToken(tokenOptions);
        return tokenString;
    }
    public string GenerateRefreshToken()
    {
        var randomNumber = new byte[32];
        using (var rng = RandomNumberGenerator.Create())
        {
            rng.GetBytes(randomNumber);
            return Convert.ToBase64String(randomNumber);
        }
    }

    public ClaimsPrincipal GetPrincipalFromExpiredToken(string token)
    {
        var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
        var jwtSettings = _configuration.GetSection("JwtSettingsCommon");
        var environment = _configuration.GetSection("Production");
        if (env == "Development")
        {
            environment = _configuration.GetSection("Development");
        }
        var JwtSecret = jwtSettings["SigningKey"];
        var Issuer = environment["URI"];
        var Audience = environment["URI"];
        var tokenValidationParameters = new TokenValidationParameters
        {
            ValidateAudience = true,
            ValidateIssuer = true,
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret)),
            ValidateLifetime = false,
            ValidIssuer = Issuer,
            ValidAudience = Audience
        };
        var tokenHandler = new JwtSecurityTokenHandler();
        SecurityToken securityToken;
        var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out securityToken);
        var jwtSecurityToken = securityToken as JwtSecurityToken;
        if (jwtSecurityToken == null || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
            throw new SecurityTokenException("Invalid token");
        return principal;
    }
}

ITokenService.cs

public interface ITokenService
{
    string GenerateAccessToken(IEnumerable<Claim> claims);
    string GenerateRefreshToken();
    ClaimsPrincipal GetPrincipalFromExpiredToken(string token);
}

JwtToken.cs

 public class JwtToken
 {
     public string Token { get; set; } = string.Empty;
     public string RefreshToken { get; set; } = string.Empty;
 }

Solution

  • Change your GenerateAccessToken method like below to ensure the Audience is not added repeatedly.

    public string GenerateAccessToken(IEnumerable<Claim> claims)
    {
        var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
        var jwtSettings = _configuration.GetSection("JwtSettingsCommon");
        var environment = _configuration.GetSection("Production");
        if (env == "Development")
        {
            environment = _configuration.GetSection("Development");
        }
        var JwtSecret = jwtSettings["SigningKey"];
        var Issuer = environment["URI"];
        var Audience = environment["URI"];
        var secretKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret));
        var signinCredentials = new SigningCredentials(secretKey, SecurityAlgorithms.HmacSha256);
    
        int expiry;
        try
        {
            expiry = Int32.Parse(jwtSettings["Expires"]);
        }
        catch (FormatException)
        {
            expiry = 10;
        }
       // Suggestion: Remove duplicate audiences if any
       var distinctAudiences = claims.Where(c => c.Type == JwtRegisteredClaimNames.Aud).Select(c => c.Value).Distinct().ToList();
       var filteredClaims = claims.Where(c => c.Type != JwtRegisteredClaimNames.Aud).ToList();
       filteredClaims.Add(new Claim(JwtRegisteredClaimNames.Aud, Audience));
    
        var tokenOptions = new JwtSecurityToken(
            issuer: Issuer,
            audience: filteredClaims,                
            claims: claims,
            expires: DateTime.Now.AddMinutes(expiry),
            signingCredentials: signinCredentials
        );
        var tokenString = new JwtSecurityTokenHandler().WriteToken(tokenOptions);
        return tokenString;
    }