Intro

In this time, I will try signing in and signing out with my React application.
I will access PostgreSQL by Entity Framework Core(Npgsql) and create tables by DB migrations First.

My project is as same as the last time I used.

DB migrations

For create "ApplicationUser" table, I create a DbContext class and "ApplicationUser" entity class as same as below post.

But then I try creating a migration file, I get an exception.

An error occurred while accessing the Microsoft.Extensions.Hosting services. Continuing without the application service provider. Error: The entry point exited without ever building an IHost.
Unable to create a 'DbContext' of type 'RuntimeType'. The exception 'Unable to resolve service for type 'Microsoft.EntityFrameworkCore.DbContextOptions`1[OfficeFileAccessor.OfficeFileAccessorContext]' while attempting to activate 'OfficeFileAccessor.OfficeFileAccessorContext'.' was thrown while attempting to create an instance. For the different patterns supported at design time, see https://go.microsoft.com/fwlink/?linkid=851728

To avoid this probrem, I have to add "builder.Services.AddRazorPages();" into Program.cs.

[Server-Side] Program.cs

using System.Text;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
using Microsoft.IdentityModel.Tokens;
using NLog;
using NLog.Web;
using OfficeFileAccessor;
using OfficeFileAccessor.AppUsers.Repositories;
using OfficeFileAccessor.OfficeFiles;
...
    var builder = WebApplication.CreateBuilder(args);
...
    builder.Services.AddDbContext<OfficeFileAccessorContext>(options =>
        options.UseNpgsql(builder.Configuration.GetConnectionString("OfficeFileAccessor")));


    // ---- Add this line ----    
    builder.Services.AddRazorPages();

...
    builder.Services.AddControllers()
        .AddJsonOptions(options =>
        {
            // stop reference loop.
            options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
        });
    builder.Services.AddScoped<IApplicationUsers, ApplicationUsers>();
    var app = builder.Build();
...
    app.Run();
...

Sign-in from the React application

Server-side

After completing authentication with E-mail address and password, I will set a JWT into Http cookie
to confirm the sign-in status.

[Server-Side] ApplicationUserService.cs

using Microsoft.AspNetCore.Identity;
using OfficeFileAccessor.Apps;
using OfficeFileAccessor.AppUsers.DTO;
using OfficeFileAccessor.AppUsers.Entities;
using OfficeFileAccessor.AppUsers.Repositories;
using OfficeFileAccessor.Web;

namespace OfficeFileAccessor.AppUsers;

public class ApplicationUserService(SignInManager<ApplicationUser> SignIn,
    IApplicationUsers Users,
    IUserTokens Tokens): IApplicationUserService
{
    public async Task<ApplicationResult> SignInAsync(SignInValue value, HttpResponse response)
    {
        var target = await Users.GetByEmailForSignInAsync(value.Email);
        if(target == null)
        {
            return ApplicationResult.GetFailedResult("Invalid e-mail or password");
        }
        SignInResult result = await SignIn.PasswordSignInAsync(target, value.Password, false, false);
        if(result.Succeeded)
        {
            // Add generated JWT into HTTP cookie.
            response.Cookies.Append("User-Token", Tokens.GenerateToken(target), DefaultCookieOption.Get());         
            return ApplicationResult.GetSucceededResult();
        }
        return ApplicationResult.GetFailedResult("Invalid e-mail or password");
    }
    public async Task SignOutAsync(HttpResponse response)
    {
        await SignIn.SignOutAsync();
        // Remove the cookie value.
        response.Cookies.Delete("User-Token");     
    }
}

When the server-side application receives the client accesses, it will get JWT from Http cookies and set as the HTTP Authorization header.

[Server-Side] Program.cs

...
    var builder = WebApplication.CreateBuilder(args);
...
    var app = builder.Build();
...
    app.Use(async (context, next) =>
    {
        // Get JWT from the HTTP cookie.
        if(context.Request.Cookies.TryGetValue("User-Token", out string? token))
        {
            // If a token exists, set as HTTP Authorization header.
            if(string.IsNullOrEmpty(token) == false)
            {            
                context.Request.Headers.Append("Authorization", $"Bearer {token}");
            }
        }        
        await next();
    });
    app.UseStaticFiles();
    app.UseAuthentication();
    app.UseAuthorization();
...
    app.Run();
...

Client-Side

To share the sign-in status to every pages, I create a React.Context and define sign-in/sign-out functions.

[Client-Side] AuthenticationContext.tsx

import { createContext, useContext } from "react";
import { AuthenticationType } from "./authenticationType";

export const AuthenticationContext = createContext<AuthenticationType|null>(null);
export const useAuthentication = (): AuthenticationType|null => useContext(AuthenticationContext);

[Client-Side] AuthenticationProvider.tsx

import { ReactNode, useState } from "react";
import { getServerUrl } from "../web/serverUrlGetter";
import { AuthenticationContext } from "./authenticationContext";
import { getCookieValue } from "../web/cookieValues";
import { hasAnyTexts } from "../texts/hasAnyTexts";

export const AuthenticationProvider = ({children}: { children: ReactNode }) => {
    // to show or hide the sign-out button.
    const [signedIn, setSignedIn] = useState(false);
    const signIn = async (email: string, password: string) => {
        const res = await fetch(`${getServerUrl()}/api/users/signin`, {
            mode: "cors",
            method: "POST",
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({ email, password })
        });
        if(res.ok) {
            const result = await res.json();
            setSignedIn(result?.succeeded === true);
            return result;
        }
        return {
            succeeded: false,
            errorMessage: "Something wrong"
        };
    };

    const signOut = async () => {
        const res = await fetch(`${getServerUrl()}/api/users/signout`, {
            mode: "cors",
            method: "GET",
        });
        if(res.ok) {
            setSignedIn(false);
            return true;
        };
        return false;
    };
    // Access a page that requires authentication to check current sign-in status.
    const check = () => 
        fetch(`${getServerUrl()}/api/auth`, {
            mode: "cors",
            method: "GET",
        })
        .then(res => res.ok);
    return <AuthenticationContext.Provider value={{ signedIn, signIn, signOut, check }}>
        {children}
    </AuthenticationContext.Provider>
}

[Client-Side] App.tsx

import './App.css'
import {
  BrowserRouter as Router,
  Route,
  Routes,
  Link
} from "react-router-dom";
import { IndexPage } from './IndexPage';
import { RegisterPage } from './RegisterPage';
import { SigninPage } from './SigninPage';
import { AuthenticationProvider } from './auth/AuthenticationProvider';
import { SignOutButton } from './components/SignoutButton';

function App() {
  return (
    <>
      <AuthenticationProvider>
        <Router basename='/officefiles'>
        <SignOutButton />
...
        <Routes>
          <Route path="/pages/signin" element={<SigninPage />} />
          <Route path="/" element={<IndexPage />} />
...
        </Routes  >
        </Router>
      </AuthenticationProvider>
    </>
  )
}
export default App

[Client-Side] SigninPage.tsx

import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuthentication } from "./auth/authenticationContext";

export function SigninPage(): JSX.Element {
    const [email, setEmail] = useState<string>("");
    const [password, setPassword] = useState<string>("");
    const authContext = useAuthentication();
    const navigate = useNavigate();
    const handleEmailChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
        setEmail(event.target.value);
    }
    const handlePasswordChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
        setPassword(event.target.value);
    }
    const signin = () => {
        if(authContext == null) {
            console.error("No Auth context");
            return;
        }
        // If success, move to the top page. 
        authContext.signIn(email, password)
            .then(res => {
                if(res.succeeded) {
                    navigate("/");
                }                
            })
            .catch(err => console.error(err));
    };
    return <div>
        <h1>Signin</h1>
        <input type="text" placeholder="Email" value={email}
            onChange={handleEmailChanged}></input>
        <input type="password" value={password}
            onChange={handlePasswordChanged}></input>
        <button onClick={signin}>Signin</button>
    </div>
}

CSRF

Because I set JWT into HTTP cookie to store sign-in status, I will add another type of token to prevent CSRF attacks.
When the client open a page, the server-side application set tokens into the HTTP cookie.
After finishing loading the page, the client-side gets the token and puts it into the HTTP request header to sign-in.

[Server-Side] Program.cs

using System.Text;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
using Microsoft.IdentityModel.Tokens;
...
    var builder = WebApplication.CreateBuilder(args);
...
    builder.Services.AddAntiforgery(options =>
    {
        // HTTP request header name
        options.HeaderName = "X-XSRF-TOKEN";
        options.SuppressXFrameOptionsHeader = false;
    });
...
    var app = builder.Build();
...
    var antiforgery = app.Services.GetRequiredService<IAntiforgery>();
    app.Use((context, next) =>
    {
        var requestPath = context.Request.Path.Value;

        if (requestPath != null &&
            (string.Equals(requestPath, "/", StringComparison.OrdinalIgnoreCase) ||
            requestPath.StartsWith("/pages", StringComparison.CurrentCultureIgnoreCase)))
        {
            // Generate a token and put into the cookie.
            AntiforgeryTokenSet tokenSet = antiforgery.GetAndStoreTokens(context);
            if(tokenSet.RequestToken != null) {
                // To use this token on the client-side, set "HttpOnly=false" 
                context.Response.Cookies.Append("XSRF-TOKEN", tokenSet.RequestToken,
                new CookieOptions { 
                    HttpOnly = false,
                    SameSite = SameSiteMode.Lax,
                });
            }
        }
        return next(context);
    });
...
    app.Run();
...

[Server-Side] ApplicationUserController.cs

using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OfficeFileAccessor.AppUsers.DTO;

namespace OfficeFileAccessor.AppUsers;

// Automatically validates AntiforgeryToken for POST, PUT, etc.
[AutoValidateAntiforgeryToken]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public class ApplicationUserController(IAntiforgery Antiforgery, IApplicationUserService Users): Controller
{
    [AllowAnonymous]
    [HttpPost("/api/users/signin")]
    public async Task<IActionResult> ApplicationSignIn([FromBody] SignInValue value)
    {
        return Json(await Users.SignInAsync(value, Response));
    }
...
    [HttpGet("/api/auth")]
    public IActionResult CheckAuthenticationStatus()
    {
        AntiforgeryTokenSet tokenSet = Antiforgery.GetAndStoreTokens(HttpContext);
        if(tokenSet.RequestToken != null) {
            HttpContext.Response.Cookies.Append("XSRF-TOKEN", tokenSet.RequestToken,
            new CookieOptions { 
                HttpOnly = false,
                SameSite = SameSiteMode.Lax,
            });
        }
        return Ok();
    }
}

[Client-Side] AuthenticationProvider.tsx

import { ReactNode, useState } from "react";
import { getServerUrl } from "../web/serverUrlGetter";
import { AuthenticationContext } from "./authenticationContext";
import { getCookieValue } from "../web/cookieValues";
import { hasAnyTexts } from "../texts/hasAnyTexts";

export const AuthenticationProvider = ({children}: { children: ReactNode }) => {
    const [signedIn, setSignedIn] = useState(false);
    const signIn = async (email: string, password: string) => {
        // Get AntiforgeryToken
        const cookieValue = getCookieValue("XSRF-TOKEN"); 

        if(!hasAnyTexts(cookieValue)) {
            throw Error("Invalid token");
        }
        // Set the token into the HTTP request header.
        const res = await fetch(`${getServerUrl()}/api/users/signin`, {
            mode: "cors",
            method: "POST",
            headers: {
                "Content-Type": "application/json",
                "X-XSRF-TOKEN": cookieValue,
            },
            body: JSON.stringify({ email, password })
        });
        if(res.ok) {
            const result = await res.json();
            setSignedIn(result?.succeeded === true);
            return result;
        }
        return {
            succeeded: false,
            errorMessage: "Something wrong"
        };
    };
...

cookieValues.ts

export function getCookieValue(name: string): string|null { 
    const value = `; ${document.cookie}`;
    const parts = value.split(`; ${name}=`); 
    if (parts.length === 2) {
        const result = parts.pop()?.split(';')?.shift();;
        if(result != null) {
            return result;
        }
    }
    return null; 
};

Author Of article : Masui Masanori Read full article