I'm learning about integration tests and am applying my lessons to a .NET Web API project. The API uses Json Web Tokens for authentication and authorization. I'm writing my first test that uses a JWT, and the test seems to be mishandling the token in a way that is not obvious to me. When I retrieve the token from a response and include it in a subsequent request, the API returns 401 Unauthorized. When I duplicate the token generation code into the Integration Test project and make a token in the test, the request is authorized. This tells me that I'm doing something incorrectly when trying to retrieve the token from the API response, but after researching on answers I'm stumped on what the problem is.
The controller method that generates the token used in the test:
// POST /start-registration
[HttpPost("start-registration")]
[AllowAnonymous]
public async Task<ActionResult<string>> StartRegistrationAsync([FromBody] RegistrationDto dto)
{
try
{
if (validator.IsValid(dto))
{
var acc = await GetUnregisteredAccountAsync(dto);
if(acc is not null)
{
if (acc.IsAuthenticated<RegistrationDto>(dto))
{
if (acc.IsAuthorized())
{
await acc.RecordAuthnActivityAsync("Success");
return CreateRegistrationToken(config);
}
else
{
await acc.RecordAuthnActivityAsync("Failure");
return Unauthorized(UnauthorizedMessage());
}
}
else
{
await acc.RecordAuthnActivityAsync("Failure");
return NotFound(NotFoundMessage());
}
}
else
{
return NotFound(NotFoundMessage());
}
}
else
{
return BadRequest(BadRequestMessage());
}
}
catch (MySqlException ex)
{
exHandler.HandleException(ex, logger);
return StatusCode(500);
}
}
The controller method that requires the token:
// POST /complete-registration
[HttpPost("complete-registration")]
[Authorize(Roles = REGISTRATION_ONLY)]
public async Task<ActionResult> CompleteRegistrationAsync([FromBody] RegistrationDto dto)
{
try
{
if (validator.IsValid(dto))
{
await RegisterAccountAsync(dto);
return Ok(OkMessage());
}
else
{
return BadRequest(BadRequestMessage());
}
}
catch (MySqlException ex)
{
exHandler.HandleException(ex, logger);
// Error code 1062 is thrown when a duplicate entry is attempted in a column
with a UNIQUE constraint.
// Will occur if the desired username already exists.
if(ex.Number == 1062)
{
return Conflict("Username already exists.");
}
else
{
return StatusCode(500);
}
}
}
The API method that generates the token:
public static string CreateRegistrationToken(IConfiguration config)
{
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Key"]!));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(ClaimTypes.Role, "Registration Only")
};
var token = new JwtSecurityToken(config["Jwt:Issuer"], config["Jwt:Audience"], claims, expires: DateTime.Now.AddHours(1), signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
The Test method (Will be refactored later):
[Fact]
public async Task RegisterAccount_WithRegistrationSuccess_ReturnsOk()
{
// Arrange
NewAccountDto newAccdto = new()
{
FirstName = "Jane",
LastName = "Doe",
PhoneNumber = "1234567890",
Location = "Some City",
Position = "Crew",
Shift = "Day",
Status = "Active"
};
var response = await client.PostAsJsonAsync("/account/add-new", newAccdto);
var registrationCode = await response.Content.ReadFromJsonAsync<int>();
RegistrationDto regDto = new()
{
RegistrationStage = RegStage.START,
FirstName = "Jane",
LastName = "Doe",
RegistrationCode = registrationCode
};
response = await client.PostAsJsonAsync("/account/start-registration", regDto);
var token = await response.Content.ReadAsStringAsync();
regDto = new()
{
RegistrationStage = RegStage.COMPLETE,
FirstName = "Jane",
LastName = "Doe",
NewUsername = "NewUsername",
NewPassword = "NewPassword"
};
// Act
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
response = await client.PostAsJsonAsync("/account/complete-registration", regDto);
// Assert
response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
}
The authentication and authorization configuration in Program.cs:
// Add JWT Authentication.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
};
});
// Add default authorization policy
builder.Services.AddAuthorization(options => options.DefaultPolicy = new AuthorizationPolicyBuilder()
.RequireClaim(ClaimTypes.Role, new string[] { "Crew", "Manager", "General Manager", "Administrator",
"Recovery Only", "Registration Only"})
.Build());
The test failure occurs with the last line in the test. The API returns 401 instead of 200. I can only reason that I'm doing something incorrectly in the test, and I haven't been able to figure out what exactly.
I have tried:
- Changing the return type of the first controller method to a Data Transfer Object that encapsulates the token, and the corresponding test call to (await ...ReadFromJsonAsync<DtoType>()).Value;.
- Changing the authentication configuration in Program.cs to explicitly configure the authentication scheme for DefaultAuthenticationScheme and DefaultChallengeScheme.
- Removing additional authorization policies that used to be in Program.cs and renaming the authorization decisions to roles, then adding the new roles to the default policy.
- Duplicating CreateRegistrationToken() inside the Integration Test code and calling it when assigning a DefaultAuthenticationHeaderValue to the HttpClient.
When I duplicate the token generation code in the integration test, the test succeeds. I've tested the endpoints separately in Postman and the authentication and authorization schemes behave as they're expected to.
EDIT 1: I forgot to include that the integration test runs the API in-memory using WebApplicationFactory, which is also used to create the HttpClient.
EDIT 2:: After posting this question, a suggested result appeared here that addresses some issues with the TestServer and Json token usage. The fix demonstrated in the referenced blog does not resolve the problem for me. I'm realizing from further literature that this may be an issue with the test server and not the code itself. I have found an alternative solution which sets AllowAnonymous for all endpoints when the environment is set to Development, but I'm not marking this as a solution yet because I would really like to be able to test the authentication and authorization schemes as part of the integration tests.