I have a WebApi and Razor frontend project (both using .Net 8.0). I'm trying to learn to implement authentication using JWT and cookies.
As of now it is working well:
- Login on my razor website, which will send the credentials to my webapi
- if login success, the api returns with jwt cookies (access and refresh)
But there is another thing that I'm trying to do on my Razor website, I want to get some info of the logged-in account by sending request to the API before the page loads. Which means changing the page elements on server-side (e.g. only show certain menus based on account permissions).
I tried this on OnGetAsync(), but the http result is 401 Unauthorized. I checked and the jwt cookies are not sent. I was assuming that it will be similar to ajax (withCredentials: true) that it will automatically include the cookies from the browser.
[BindProperty]
public Account Account { get; set; }
public async Task<IActionResult> OnGetAsync()
{
string api = "http://localhost:5192/api/";
string path = "accounts/info";
var httpClient = new HttpClient();
httpClient.BaseAddress = new Uri(api);
// send GET request of the logged-in account's info (id, name, permissions, etc.)
// but this results in 401 Unauthorized, so the jwt cookies are not included in the request?
var message = await httpClient.GetAsync(path);
Account = await message.Content.ReadFromJsonAsync<Account>();
return Page(); // after the request finishes, then load the page
}
TESTING
Calling API request via AJAX (result OK, also sending cookies)
$.ajax({
url: "http://localhost:5192/api/accounts/check", // directly call the api
type: "get",
xhrFields: {
withCredentials: true
},
processData: false,
contentType: false,
success: (data, textStatus, jqXHR) => {
console.log(data);
},
error: (jqXHR, textStatus, errorThrown) => {
console.error(jqXHR);
}
});
Calling API request via HttpClient (result Unauthorized)
$.ajax({
url: "?handler=Check", // call the page handler method
type: "get",
xhrFields: {
withCredentials: true
},
processData: false,
contentType: false,
success: (data, textStatus, jqXHR) => {
console.log(data);
},
error: (jqXHR, textStatus, errorThrown) => {
console.error(jqXHR);
}
});
// the handler method in Razor page
public class IndexModel : PageModel
{
public async Task<IActionResult> OnGetCheckAsync()
{
string api = "http://localhost:5192/api/";
string path = "accounts/check";
var httpClient = new HttpClient();
httpClient.BaseAddress = new Uri(api);
var message = await httpClient.GetAsync(path);
string content = await message.Content.ReadAsStringAsync();
return StatusCode(((int)message.StatusCode), content);
}
}
The WebApi code
Program.cs
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.Configure<MyConfiguration>(builder.Configuration);
// some code here...
var authentication = builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
});
authentication.AddJwtBearer(options =>
{
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = config.Tokens?.Issuer,
ValidAudience = config.Tokens?.Audience,
ClockSkew = TimeSpan.Zero,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config.Tokens?.Key!)),
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = ctx =>
{
var cookieName = "";
if (ctx.Request.Path.Equals("/api/accounts/refresh-token", StringComparison.OrdinalIgnoreCase))
{
// get refresh token
cookieName = config.Cookies?.RefreshFullName;
}
else
{
// get access token
cookieName = config.Cookies?.AccessFullName;
}
ctx.Request.Cookies.TryGetValue(cookieName, out var token);
if (!string.IsNullOrEmpty(token))
{
ctx.Token = token;
}
return Task.CompletedTask;
},
OnAuthenticationFailed = ctx =>
{
if (ctx.Exception.GetType() == typeof(SecurityTokenExpiredException))
{
ctx.Response.Headers.Append("X-Token-Expire", "true");
}
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(builder =>
builder
.SetIsOriginAllowed(host => new Uri(host).Host == "localhost")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()
);
});
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddDbContext<TicketingDbContext>(optionsBuilder =>
{
optionsBuilder.UseMySql(config.TicketingConnectionString!, ServerVersion.AutoDetect(config.TicketingConnectionString));
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
AccountsController.cs
[Authorize]
[Route("api/[controller]")]
[ApiController]
public class AccountsController : ControllerBase
{
private readonly TicketingDbContext _dbContext;
private readonly IOptions<MyConfiguration> _configuration;
private readonly ITokenFactory _tokenFactory; // DI service for generating JWT tokens
public AccountsController(TicketingDbContext dbContext, IOptions<MyConfiguration> configuration, ITokenFactory tokenFactory)
{
_dbContext = dbContext;
_configuration = configuration;
_tokenFactory = tokenFactory;
}
[AllowAnonymous]
[HttpPost("Login")]
public async Task<IActionResult> Login([FromForm]LoginModel login)
{
var account = await _dbContext.Accounts
.AsNoTracking()
.Include(a => a.Role)
.FirstOrDefaultAsync(x => x.Email == login.Email);
if (account == null)
{
return Unauthorized("Invalid Email/Password");
}
if (!account.IsPasswordMatch(login.Password))
{
return Unauthorized("Invalid Email/Password");
}
// ------------------- begin save login trail -------------------
// save to database as login session identifier
Guid guid = Guid.NewGuid();
// save to database as token lookup
Guid accessGuid;
Guid refreshGuid;
DateTime? refreshExpire;
// generate new access & refresh tokens and add to cookies
GenerateAccessRefreshTokens(out accessGuid, out refreshGuid, out refreshExpire);
_dbContext.TokenTrails
.Add(new TokenTrail()
{
SessionId = guid,
AccountId = account.Id,
AccessId = accessGuid,
RefreshId = refreshGuid,
RefreshExpire = (DateTime)refreshExpire!,
UserAgent = GetClientUserAgent(),
IpAddress = GetClientIpAddress(),
});
await _dbContext.SaveChangesAsync();
return Ok();
}
[HttpGet("Check")]
public async Task<IActionResult> Check()
{
return Ok(); // this is only for checking if already login
}
[HttpPost("Logout")]
public async Task<IActionResult> Logout()
{
// some code here...
}
[HttpPost("Refresh-Token")]
public async Task<IActionResult> RefreshToken()
{
// some code here for refreshing/rotating tokens...
}
private void GenerateAccessRefreshTokens(out Guid accessGuid, out Guid refreshGuid, out DateTime? refreshExpire)
{
accessGuid = Guid.NewGuid();
refreshGuid = Guid.NewGuid();
string accessToken = _tokenFactory.GenerateAccessToken(new[]
{
new Claim("jti", accessGuid.ToString())
}, out _);
string refreshToken = _tokenFactory.GenerateRefreshToken(new[]
{
new Claim("jti", refreshGuid.ToString())
}, out refreshExpire);
// add tokens to cookies, send back to client for authentication
Response.Cookies.Append(
_configuration.Value.Cookies?.AccessFullName!,
accessToken,
new CookieOptions
{
IsEssential = true,
HttpOnly = true,
Secure = Request.Scheme == "https",
SameSite = SameSiteMode.Strict,
Expires = DateTimeOffset.UtcNow.AddYears(1),
Domain = Request.Host.Host,
Path = "/api" // limit this cookie to be shown in this specific api path
}
);
Response.Cookies.Append(
_configuration.Value.Cookies?.RefreshFullName!,
refreshToken,
new CookieOptions
{
IsEssential = true,
HttpOnly = true,
Secure = Request.Scheme == "https",
SameSite = SameSiteMode.Strict,
Expires = DateTimeOffset.UtcNow.AddYears(1),
Domain = Request.Host.Host,
Path = "/api/accounts/refresh-token" // limit this cookie to be shown in this specific api path
}
);
}
private string? GetClientIpAddress()
{
return Request.HttpContext.Connection.RemoteIpAddress?.MapToIPv4().ToString();
}
private string? GetClientUserAgent()
{
return Request.Headers.UserAgent.ToString();
}
}
Note: I did not include the code for generating the tokens, since it is working if called by ajax.