Schémas d’architecture d’authentification pour projets .NET / React

Suite à mon article sur le choix entre JWT, cookies et OAuth2 sur un projet .NET / React, voici une version orientée schémas d’architecture : qui parle à la fois au dev, à l’architecte et à l’ops.

Pour chaque cas, tu as :

  • un diagramme ASCII simple,
  • le flux d’authentification,
  • un extrait de code .NET,
  • et un exemple React quand c’est pertinent.

1) Architecture SPA React + API .NET avec JWT + refresh en cookie HttpOnly

Cas typique : une SPA React qui discute avec une API .NET hébergée séparément, le tout derrière un reverse proxy (NGINX, Azure App Gateway, etc.).

+------------------+      HTTPS       +--------------------+        +-------------+
|  Navigateur      | --------------> |  API .NET (REST)   | -----> |   SQL / DB   |
|  React SPA       | <-------------- | + Auth / JWT       | <----- |  (utilisateurs,
|                  |  JSON + JWT     |                    |        |   tokens...) |
+------------------+                 +--------------------+        +-------------+
       ^                                         ^
       |                                         |
       | cookie HttpOnly ".fb.r" (refresh)       |
       +-----------------------------------------+
                     /auth/refresh

Idée : l’access token vit en mémoire (ou dans un state manager), le refresh token est dans un cookie HttpOnly. L’API est complètement stateless.

Flux simplifié

  1. L’utilisateur soumet son email + mot de passe à /api/auth/login.
  2. L’API valide, génère un access token (JWT court) + un refresh token.
  3. Le refresh est envoyé dans un cookie HttpOnly, l’access token dans la réponse JSON.
  4. La SPA stocke l’access token en mémoire et le met en en-tête Authorization: Bearer.
  5. Quand l’access expire, la SPA appelle /api/auth/refresh (cookie envoyé automatiquement).

.NET – configuration minimale JWT + CORS pour SPA

// Program.cs (exemple condensé JWT + CORS SPA)

using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

builder.Services.AddCors(options =>
{
    options.AddPolicy("spa", p =>
        p.WithOrigins("http://localhost:3000", "https://farzadbagheri.fr")
         .AllowAnyHeader()
         .AllowAnyMethod()
         .AllowCredentials());
});

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = "https://auth.farzadbagheri.fr",
            ValidAudience = "fb-api",
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!)
            )
        };
    });

builder.Services.AddAuthorization();

var app = builder.Build();

app.UseHttpsRedirection();
app.UseCors("spa");
app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

app.Run();

.NET – endpoint /auth/refresh (rotation simple)

// AuthController.cs (extrait - refresh simple)

[HttpPost("refresh")]
public async Task<IActionResult> Refresh()
{
    var refresh = Request.Cookies[".fb.r"];
    if (string.IsNullOrEmpty(refresh)) return Unauthorized();

    var result = await _tokenService.ValidateAndRotateAsync(refresh);
    if (!result.Success) return Unauthorized();

    // Nouveau refresh token en HttpOnly
    Response.Cookies.Append(".fb.r", result.NewRefreshToken, new CookieOptions
    {
        HttpOnly = true,
        Secure = true,
        SameSite = SameSiteMode.Lax,
        Expires = DateTimeOffset.UtcNow.AddDays(7)
    });

    // Access token renvoyé en JSON
    return Ok(new { accessToken = result.AccessToken });
}

React – hook d’auth simplifié

// useAuth.ts (simplifié)

import { useState, useCallback } from "react";

let accessToken: string | null = null;

export function useAuth() {
  const [user, setUser] = useState<{ email: string } | null>(null);

  const login = useCallback(async (email: string, password: string) => {
    const res = await fetch("/api/auth/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify({ email, password })
    });

    if (!res.ok) throw new Error("Bad credentials");
    const data = await res.json();
    accessToken = data.accessToken;
    setUser({ email: data.user.email });
  }, []);

  const apiGet = useCallback(async (url: string) => {
    const res = await fetch(url, {
      headers: accessToken
        ? { Authorization: `Bearer ${accessToken}` }
        : undefined,
      credentials: "include"
    });

    if (res.status === 401) {
      const r = await fetch("/api/auth/refresh", {
        method: "POST",
        credentials: "include"
      });
      if (r.ok) {
        const { accessToken: newToken } = await r.json();
        accessToken = newToken;
        const retry = await fetch(url, {
          headers: { Authorization: `Bearer ${newToken}` },
          credentials: "include"
        });
        if (!retry.ok) throw new Error("API error");
        return retry.json();
      }
    }

    if (!res.ok) throw new Error("API error");
    return res.json();
  }, []);

  return { user, login, apiGet };
}

2) Architecture MVC / Razor avec cookies + auth server-side

Architecture classique pour un intranet ou un back-office : tout est rendu côté serveur, quelques scripts JS pour enrichir l’UI, mais pas de grosse SPA.

+------------------+       HTTPS        +--------------------------+       +-------------+
| Navigateur       | <----------------->|  ASP.NET Core MVC /     | ----->| SQL / LDAP  |
| (HTML + Razor)   |   HTML + cookies   |  Razor (Auth + Views)   | <----- | (utilisateurs)
+------------------+                    +--------------------------+       +-------------+
            ^                                         |
            | cookie ".fb.auth" (ClaimsPrincipal)     |
            +-----------------------------------------+

Le cookie contient un ClaimsPrincipal signé par le serveur. La logique d’autorisation se fait côté serveur via les attributs [Authorize], les policies, etc.

.NET – configuration cookies

// Program.cs (MVC + Cookies)

using Microsoft.AspNetCore.Authentication.Cookies;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

builder.Services
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.Name = ".fb.auth";
        options.LoginPath = "/Account/Login";
        options.AccessDeniedPath = "/Account/Denied";
        options.SlidingExpiration = true;
        options.ExpireTimeSpan = TimeSpan.FromHours(8);
        options.Cookie.HttpOnly = true;
        options.Cookie.SameSite = SameSiteMode.Lax;
    });

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", p => p.RequireRole("Admin"));
});

var app = builder.Build();

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

app.MapDefaultControllerRoute();

app.Run();

Contrôleur de login + page protégée

// AccountController.cs (extrait)

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginViewModel model)
{
    if (!ModelState.IsValid) return View(model);

    var user = await _users.ValidateAsync(model.Email, model.Password);
    if (user is null)
    {
        ModelState.AddModelError("", "Identifiants incorrects");
        return View(model);
    }

    var claims = new List<Claim>
    {
        new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
        new Claim(ClaimTypes.Name, user.DisplayName),
        new Claim(ClaimTypes.Role, user.Role)
    };

    var identity = new ClaimsIdentity(
        claims, CookieAuthenticationDefaults.AuthenticationScheme);

    await HttpContext.SignInAsync(
        CookieAuthenticationDefaults.AuthenticationScheme,
        new ClaimsPrincipal(identity));

    return RedirectToAction("Index", "Home");
}

[Authorize(Policy = "AdminOnly")]
public IActionResult AdminDashboard()
{
    return View();
}

Ici, pas besoin de gérer des tokens côté front : le navigateur envoie automatiquement le cookie sur chaque requête, et ASP.NET reconstruit le User côté serveur.


3) Architecture entreprise : IdP (Azure AD) + API .NET + React

Dans beaucoup de contextes entreprise, l’authentification est centralisée dans un Identity Provider (Azure AD / Entra ID, Okta, Auth0…).

         +-----------------------+
         |  Azure AD / IdP       |
         |  (OpenID Connect)     |
         +-----------+-----------+
                     ^
   (redir OIDC)      | 1) Login / Consent
                     |
+--------------------+---------------------+
|  WebApp .NET (MVC / Razor / minimal)    |
|  + React SPA hébergée                   |
+--------------------+--------------------+
                     |
         3) JWT interne / cookie
                     |
             HTTPS   v
         +-----------+-----------+        +-------------+
         |     API .NET (REST)   | -----> |  SQL / DB    |
         |  Auth = JWT interne   |        |  (utilisateurs,
         +-----------------------+        |   permissions)
                                          +-------------+

Flux simplifié

  1. L’utilisateur est redirigé vers Azure AD (OpenID Connect).
  2. Après login, Azure AD renvoie un id_token + access_token à l’app .NET.
  3. L’app mappe l’utilisateur vers un compte interne (création/sync si besoin).
  4. L’app émet un JWT interne pour l’API, ou signe un cookie.
  5. La SPA consomme l’API avec ce JWT interne (ou via un BFF suivant la stratégie).

.NET – configuration OpenID Connect + Cookie

// Program.cs (WebApp avec OIDC Azure AD + cookie)

using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
    })
    .AddCookie(options =>
    {
        options.Cookie.Name = ".fb.oidc";
        options.SlidingExpiration = true;
    })
    .AddOpenIdConnect(options =>
    {
        options.Authority = "https://login.microsoftonline.com/{tenant-id}/v2.0";
        options.ClientId = builder.Configuration["AzureAd:ClientId"]!;
        options.ClientSecret = builder.Configuration["AzureAd:ClientSecret"]!;
        options.ResponseType = "code";
        options.SaveTokens = true; // garde les tokens d'Azure AD
        options.Scope.Add("email");
        options.Scope.Add("profile");
        options.CallbackPath = "/signin-oidc";
    });

var app = builder.Build();

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

app.MapDefaultControllerRoute();

app.Run();

.NET – endpoint pour émettre le JWT interne consommé par React

// ApiTokenController.cs (WebApp)

[Authorize]
[ApiController]
[Route("api/internal-token")]
public class ApiTokenController : ControllerBase
{
    private readonly IUserSyncService _sync;
    private readonly IConfiguration _config;

    public ApiTokenController(IUserSyncService sync, IConfiguration config)
    {
        _sync = sync;
        _config = config;
    }

    [HttpPost]
    public async Task<IActionResult> Create()
    {
        var externalId = User.FindFirst("oid")?.Value
                         ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        var email = User.FindFirst(ClaimTypes.Email)?.Value;

        if (externalId is null || email is null)
            return BadRequest("Profil OIDC incomplet");

        var user = await _sync.EnsureUserAsync(externalId, email, User);

        var token = CreateInternalJwt(user);
        return Ok(new { accessToken = token });
    }

    private string CreateInternalJwt(AppUser user)
    {
        var key = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(_config["Jwt:Key"]!)
        );
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
            new Claim("role", user.Role),
            new Claim("tenant", user.TenantId.ToString())
        };

        var jwt = new JwtSecurityToken(
            issuer: "https://auth.farzadbagheri.fr",
            audience: "fb-api",
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(30),
            signingCredentials: creds
        );

        return new JwtSecurityTokenHandler().WriteToken(jwt);
    }
}

React – récupération du JWT interne après login OIDC

Ici, la partie « login Azure AD » est gérée par le backend (redirections). La SPA récupère simplement le token interne après coup.

// useInternalToken.ts

import { useEffect, useState } from "react";

export function useInternalToken() {
  const [token, setToken] = useState<string | null>(null);

  useEffect(() => {
    (async () => {
      const res = await fetch("/api/internal-token", {
        method: "POST",
        credentials: "include"
      });
      if (res.ok) {
        const data = await res.json();
        setToken(data.accessToken);
      }
    })();
  }, []);

  return token;
}

Résumé

  • SPA publique → JWT + refresh en cookie HttpOnly, API stateless.
  • Intranet / back-office → cookies + auth server-side (MVC, Razor).
  • App entreprise avec SSO → OpenID Connect (Azure AD) + JWT interne.

À chaque fois, le schéma d’auth doit rester lisible sur un tableau blanc. Si tu n’arrives pas à le dessiner clairement, c’est probablement trop compliqué pour l’équipe qui devra le maintenir en production.

← Retour au blog