JWT authentication and authorization using .NET and React

Posted by : on

Category : Authorization guides

Why you may want to read this article

This article is a simple guide about how to create JWT authorization using Backend pure (.NET + Identity) + Web Client (in our case React).

I saw this flow 2 times in commercial projects I was working on, it is simple, it is workable to some extent. It has drawbacks, but it is cheap to implement nonetheless.

In Identity docs, you can see Razor pages (custom UI) for auth. But usually it is not what you want to do, usually, you have a separated backend and frontend (Client Server architecture). So in this guide, we will do auth using the pure backend.

If you are looking for good client implementations it is not a guide for you, I mostly will discuss the backend part.

This article will touch on authentication and authorization concepts, so you could check articles about auth basics, cookies auth in .NET, OAuth internals


Flow

In this particular sample, our server acts similarly to the authorization server in OAuth protocol, and the flow looks like Resource Owner Password Credentials of OAuth (rfc reference).

The resource server (or just endpoints) will be protected by authorization middleware that will check specific tokens called JWT (JSON Web Token).

JWT works in the following way (I simplify a lot):

  • JWT consists of the user’s data called Claims (email, username, id, role, id). And this information is base64 encoded, it could be even encrypted in some authorization server implementations.
  • Whenever we create JWT we create a signature based on this user data and add this signature to the payload.
  • Whenever server gets the JWT it extracts this payload and creates the same signature using the secret it has. If the signatures are equal - you can trust this token and this user is valid.

The flow:

  • The client tries to access some protected endpoint - it gets 401 UnAuthorized.
  • The client redirects the user to the login page, the user fills in his username and password
  • The client sends those credentials to the backend.
  • The backend verifies the password using Identity.
  • The backend creates JWT if the user exists and has the correct password.
  • The client receives a response from the backend and tries to access protected endpoints with this token (in the header).


Backend

For the backend, we need to create a project, identity, and database for it.


Database

Create a Postgres database (I prefer through docker).

Here is guide that covers all commands, so you can do it in few clicks.

docker volume create pgdata

docker run -p 5432:5432 --name postgres -v pgdata:/var/lib/postgresql/data -e POSTGRES_PASSWORD=root -e POSTGRES_USER=root -d postgres

In the end, you should have postgres running on a particular port, for simplicity, it is 5432 in my case.

alt_text


API

Source code

1. Create pure Web API project

For our purpose, I’ll name it “Server”.

VS -> Create a new project -> ASP.NET Core Web API -> (fill in name and location) -> configure HTTPS checked -> Create

Install a bunch of nuggets:

Install Microsoft.AspNetCore.Identity.EntityFrameworkCore NuGet.

Install Npgsql.EntityFrameworkCore.PostgreSQL NuGet.

Install Microsoft.AspNetCore.Authentication.JwtBearer NuGet.

Install Microsoft.AspNetCore.Identity.UI NuGet.

Install Microsoft.AspNetCore.Authentication.JwtBearer NuGet.

Install Microsoft.EntityFrameworkCore.Design NuGet (for running the DB migrations).

Install Microsoft.EntityFrameworkCore.Tools NuGet (for running the DB migrations).

Also set the application URL (local application URL) to “applicationUrl”: “https://localhost:7000”.

You can do that in Properties -> launchSettings.json


2. Add EF context

AuthContex.cs

Source code

public class AuthContext : IdentityDbContext<IdentityUser>
{
    public AuthContext(DbContextOptions<AuthContext> options)
        : base(options)
    {

    }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);
    }
}

Then patch settings

appsettings.json

Source code

{
  "Secret": "secret*secret123secret444",

  "ConnectionStrings": {
    "AuthContextConnection": "Host=127.0.0.1;Port=5432;Database=AuthSampleDb5;Username=root;Password=root"
  }
}

Lets create another file for our default username and login

Consts.cs

Source code


public class Consts
{
    public const string UserName = "andreyka26_";
    public const string Password = "Mypass1*";
}


3. Patch Program class

Progam.cs

Source code

Add swagger (with authentication for jwt)


builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo { Title = "PetForPet.Api", Version = "v1" });

    c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Description = @"JWT Authorization header using the Bearer scheme. \r\n\r\n
                      Enter 'Bearer' [space] and then your token in the text input below.
                      \r\n\r\nExample: 'Bearer 12345abcdef'",
        Name = "Authorization",
        In = ParameterLocation.Header,
        Type = SecuritySchemeType.ApiKey,
        Scheme = "Bearer"
    });

    c.AddSecurityRequirement(new OpenApiSecurityRequirement()
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "Bearer"
                },
                Scheme = "oauth2",
                Name = "Bearer",
                In = ParameterLocation.Header
            },
            new List<string>()
        }
    });
});

Register auth services and middlewares


var connectionString = builder.Configuration.GetConnectionString("AuthContextConnection");

builder.Services.AddDbContext<AuthContext>(options => options.UseNpgsql(connectionString));
builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = false)
    .AddEntityFrameworkStores<AuthContext>();

builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(x =>
{
    var secret = builder.Configuration.GetValue<string>("Secret");
    var key = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(secret));
    x.RequireHttpsMetadata = true;
    x.SaveToken = true;
    x.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuerSigningKey = true,
        ValidAudience = "https://localhost:7000/",
        ValidIssuer = "https://localhost:7000/",
        IssuerSigningKey = key,
        ValidateLifetime = true,
        ClockSkew = TimeSpan.Zero
    };
});

Then after

var app = builder.Build();

Put the code to use our registered auth and swagger middlewares:


app.UseCors(builder =>
{
    builder
        .AllowAnyOrigin()
        .AllowAnyMethod()
        .AllowAnyHeader();
});

app.UseSwagger();
app.UseSwaggerUI();

app.UseHttpsRedirection();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

using (var scope = app.Services.CreateScope())
using (var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>())
using (var db = scope.ServiceProvider.GetRequiredService<AuthContext>())
{
    db.Database.Migrate();
    var user = await userManager.FindByNameAsync(Consts.UserName);

    if (user == null)
    {
        user = new IdentityUser(Consts.UserName);
        await userManager.CreateAsync(user, Consts.Password);
    }
}

app.Run();

On top of that we created seeding the database ot make it autocreate and created default user.


4. Create Resource Controller

Source code

This endpoint is our protected by authorization endpoint. It will user JWT to authorize the request.


[ApiController]
public class ResourcesController : ControllerBase
{
    [HttpGet("api/resources")]
    [Authorize]
    public IActionResult GetResources()
    {
        return Ok($"protected resources, username: {User.Identity!.Name}");
    }
}


5. Create Authorization Endpoint with password creds

Prior to that let’s create some request and response DTOs.

GetTokenRequest


public class GetTokenRequest
{
    public string UserName { get; set; } = Consts.UserName;
    public string Password { get; set; } = Consts.Password;
}

AuthorizationResponse


public class AuthorizationResponse
{
    public string UserId { get; set; }
    public string AuthorizationToken { get; set; }
    public string RefreshToken { get; set; }
}

AuthorizationController

Source code

AuthorizationController.GenerateAuthorizationToken


private AuthorizationResponse GenerateAuthorizationToken(string userId, string userName)
{
    var now = DateTime.UtcNow;
    var secret = _configuration.GetValue<string>("Secret");
    var key = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(secret));

    var userClaims = new List<Claim>
    {
        new Claim(ClaimsIdentity.DefaultNameClaimType, userName),
        new Claim(ClaimTypes.NameIdentifier, userId),
    };

    //userClaims.AddRange(roles.Select(r => new Claim(ClaimsIdentity.DefaultRoleClaimType, r)));

    var expires = now.Add(TimeSpan.FromMinutes(60));

    var jwt = new JwtSecurityToken(
            notBefore: now,
            claims: userClaims,
            expires: expires,
            audience: "https://localhost:7000/",
            issuer: "https://localhost:7000/",
            signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));

    //we don't know about thread safety of token handler

    var encodedJwt = new JwtSecurityTokenHandler().WriteToken(jwt);

    var resp = new AuthorizationResponse
    {
        UserId = userId,
        AuthorizationToken = encodedJwt,
        RefreshToken = string.Empty
    };

    return resp;
}

This method will be used to create JWT and return it in well defined response DTO. In my previous case we need to have username and userid in the token claims therefore we expected them.

It creates the claims, puts them into JWT object, and signs it with our secret defined in appsettings.json.

Next step is to create endpoint for standard credential based auth

AuthorizationController.GetTokenAsync


private readonly UserManager<IdentityUser> _userManager;
private readonly SignInManager<IdentityUser> _signInManager;
private readonly IUserStore<IdentityUser> _userStore;
private readonly IUserEmailStore<IdentityUser> _emailStore;
private readonly IConfiguration _configuration;

public AuthorizationController(UserManager<IdentityUser> userManager,
    IConfiguration configuration,
    SignInManager<IdentityUser> signInManager,
    IUserStore<IdentityUser> userStore)
{
    _userManager = userManager;
    _configuration = configuration;
    _signInManager = signInManager;
    _emailStore = (IUserEmailStore<IdentityUser>)userStore;
    _userStore = userStore;
}

[HttpPost("authorization/token")]
public async Task<IActionResult> GetTokenAsync([FromBody] GetTokenRequest request)
{
    var user = await _userManager.FindByNameAsync(request.UserName);

    if (user == null)
    {
        //401 or 404
        return Unauthorized();
    }

    var passwordValid = await _userManager.CheckPasswordAsync(user, request.Password);

    if (!passwordValid)
    {
        //401 or 400
        return Unauthorized();
    }

    var resp = GenerateAuthorizationToken(user.Id, user.UserName);

    return Ok(resp);
}

As you can see the behavior is simple: we check whether user with those credentials exists, check whether password matches, if yes - we generate JWT and return it for client.


6. Add Migrations

Go to Package Manager Console in Visual Studio.

Run this command:


Add-Migration Initial


Run and test

Run the application locally. By following “https://localhost:7000/swagger/index.html” you should be able to see swagger UI.

Try authorization endpoint with creds “andreyka26_” and “Mypass1*”

alt_text

Now you could add this authorization token to header with “Bearer {token}” and run ResourceController.

alt_text

And as response you could see that in controller our auth middleware successfully parsed username claim:

alt_text


Frontend

Source code

This application does not cover referesh token behavior - it is explained in this tutorial.

First let’s create simple react application following the official documentation. Run the following commands like described here


Create app and install dependencies


npx create-react-app my-app

cd my-app

npm install axios --save
npm install react-router-dom --save


Add code

index.js

import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);

App.js

import { BrowserRouter, Routes, Route } from "react-router-dom";

import "./App.css";

//pages

import HomePage from "./pages/Home";
import LoginPage from "./pages/Login";

export default function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/login" element={<LoginPage />} />
      </Routes>
    </BrowserRouter>
  );
}

Then create two files:

pages/Home.js

import axios from "axios";
import React, { useState, useEffect } from "react";

function HomePage() {
  const [data, setData] = useState("default");

  useEffect(() => {
    const token = localStorage.getItem("token");

    if (token) {
      axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
    }

    if (data == "default") {
      axios
        .get("https://localhost:7000/api/resources")
        .then((response) => {
          const data = response.data;

          setData(data);
        })
        .catch((err) => console.log(err));
    }
  });

  return <div>Home Page {data}</div>;
}

export default HomePage;

pages/Login.js

import React, { useState } from "react";
import axios from "axios";
import { useNavigate } from "react-router-dom";

function LoginPage() {
  const navigate = useNavigate();
  const [userName, setUserName] = useState("andreyka26_");
  const [password, setPassword] = useState("Mypass1*");

  function handleSubmit(event) {
    event.preventDefault();

    const loginPayload = {
      userName: userName,
      password: password,
    };

    axios
      .post("https://localhost:7000/authorization/token", loginPayload)
      .then((response) => {
        const token = response.data.authorizationToken;

        localStorage.setItem("token", token);

        if (token) {
          axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
        }

        navigate("/");
      })
      .catch((err) => console.log(err));
  }

  function handleUserNameChange(event) {
    setUserName({ value: event.target.value });
  }

  function handlePasswordhange(event) {
    setPassword({ value: event.target.value });
  }

  return (
    <div>
      Login Page
      <form onSubmit={handleSubmit}>
        <label>
          User Name:
          <input type="text" value={userName} onChange={handleUserNameChange} />
        </label>
        <label>
          Password:
          <input type="text" value={password} onChange={handlePasswordhange} />
        </label>
        <input type="submit" value="Submit" />
      </form>
    </div>
  );
}

export default LoginPage;


Run together

Run backend, pressing F5.

Run frontend by running npm start

Go to “http://localhost:3000/login”, and press submit button.

In network tab you should be able to see token request:

alt_text

alt_text

And then the redirection to home page which queries our backend with this token:

alt_text

alt_text

In our case we can even see what is stored in our token because we don’t encrypt it. Go to [https://jwt.io](https://jwt.io) and paste there your token.

alt_text

You can see our name and id claims we’ve created on token generation step in the backend. The same way you could add role and validate by role.

Thank you for your attention. In the next part, we will consider how to add Google Auth to this setup. It turned out that it is not easy at all.


Follow up

Please subscribe to my social media to not miss updates.: Instagram, Telegram

I’m talking about life as a Software Engineer at Microsoft.


Besides that, my projects:

Symptoms Diary: https://symptom-diary.com

Pet4Pet: https://pet-4-pet.com


About Andrii Bui

Hi, my name is Andrii. I'm Software Engineer at Microsoft with 5 years of experience.