Loupe

Authentification Azure AD simple avec une SPA dans une application aspnetcore

Il y a quelques mois, mon cher collègue Jonathan nous a fait une petite présentation en interne des différents workflows d'authentification d'une SPA sur une API aspnetcore via OpenId Connect, et l'une des conclusions de sa présentation me reste particulièrement en tête :

Au final, l'authentification par cookiesest probablement l'une des manières les plus sécurisées de protéger une API.

Récemment, j'ai eu besoin de sécuriser une api aspnetcore par une simple vérification d'email provenant d'Azure AD depuis une SPA React. En faisant quelques recherches sur le net, je fus assez surpris de ne trouver que des exemples relativement "complexes` utilisant MSAL.js entre autre.

Workflow d'authentification simple

J'ai alors appelé mon autre collègue Thomas qui m'a redirigé tout d'abord sur l'intégration built-in d'Azure AD dans une web app aspnetcore (qui peut être obtenue très facilement en suivant la documentation officielle). Il m'a également dit de regarder plus précisément du côté du Microsoft.AspNetCore.Authentication middleware qui, par défaut, sécurise tous les endpoints des Controllers et effectue une redirection implicite des utilisateurs vers la page d'authentification Azure AD lorsque ceux-ci ne sont pas encore authentifiés.

Mais nous avons rencontré quelques problèmes avec cette solution :

  • Lorsque la SPA React appelait l'API, elle obtenait un status code de redirection 302 à la place d'une erreur401.
  • L'authentification par défaut ne vérifie que si l'utilisateur est bien présent dans l'annuaire Azure AD mais sans aucune autre vérification (i.e. que son email était bien présent dans notre base de données)

Du coup, toujours avec l'aide de Thomas, nous avons réussi à avoir un workflow plutôt "simple" pour authentifier nos utilisateurs SPA :

  • Lorsque l'utilisateur essaie d'accéder à une API sans être authentifié, il récupère une erreur 401.
  • Lorsque l'app React récupère une erreur 401, on redirige l'utilisateur vers un endpoint d'authentification appelé /api/auth par exemple.
  • Cet endpoint se charge de rediriger l'utilisateur vers la page de login Azure AD et appelle de nouveau l'endpoint /api/auth lorsque l'authentification est réussie et permet la génération d'un cookie d'authentification.
  • Lorsque l'utilisateur est accède à l'endpoint /api/auth en étant authentifié, on vérifie son email et l'on redirige l'utilisateur vers la page principale.
  • Par la suite, les autres appels aux API sont faits avec le Cookie d'authentification et peuvent se passer sans aucun souci.

Afin de mettre en place ce workflow, nous avons dû effectuer les quelques modifications suivantes :

  1. Au lieu de rediriger les utilisateurs vers la page de login Azure AD en cas d'accès à un endpoint d'API sans être authentifié, on renvoie une erreur 401.
  2. On configure l'authentification Azure AD pour que la vérification se fasse par Cookie (et ainsi permettre à la SPA de n'avoir "qu'à transmettre` le cookie d'authentification pour accéder aux API).
  3. Dans la SPA, appeler l'endpoint /api/auth pour s'authentifier sur Azure AD en cas d'erreur 401
  4. Dans le même endpoint d'API, vérifier l'email d'utilisateur et le déconnecter (par exemple) en cas d'accès non autorisé.

Surcharge de la redirection Azure AD par une erreur 401

Afin de réaliser ce scénario, nous avons défini comme DefaultChallengeScheme un CustomApiScheme, ainsi qu'un CustomApiAuthenticationHandler afin de retourner une erreur 401 au lieu d'une redirection :

private static readonly string CustomApiScheme = "CustomApiScheme";

private void ConfigureAuthentication(IServiceCollection services)
{
    services.AddAuthentication(options =>
    {
        options.DefaultAuthenticateScheme = AzureADDefaults.CookieScheme;
        options.DefaultChallengeScheme = CustomApiScheme;
        options.DefaultSignInScheme = AzureADDefaults.CookieScheme;
    })
    .AddAzureAD(options =>
    {
        Configuration.Bind("AzureAd", options);
    })
    .AddScheme<AuthenticationSchemeOptions, CustomApiAuthenticationHandler>(CustomApiScheme, options => { });
    ...
}
public class CustomApiAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public CustomApiAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock) { }

    protected override Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        Response.StatusCode = 401;
        return Task.CompletedTask;
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        return Task.FromResult(AuthenticateResult.NoResult());
    }
}

Utilisation deCookies pour l'authentification Azure AD

Rien d'exceptionnel ici, juste la configuration des CookieAuthenticationOptions en utilisant le AzureADDefaults.CookieScheme ainsi que les propriétés que l'on souhaite que notre Cookie ait.

private void ConfigureAuthentication(IServiceCollection services)
{
	...
	
    services.Configure<CookieAuthenticationOptions>(AzureADDefaults.CookieScheme, options =>
    {
        options.Cookie.HttpOnly = true;
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.SameSite = SameSiteMode.Lax;
    });
    
    ...

01-aspnetcore-azuread-authentication-cookie (1).png

Redirection vers l'endpoint /api/auth en cas d'erreur 401

Dans notre application d'exemple, nous n'avons qu'un simple appel API dans le fichier FetchData.js. Dans une application plus complexe, on pourra plutôt configurer le comportement de fetch, axios ou tout autre client http pour implémenter ce comportement.

async populateWeatherData() {
  ...
  if (response.status == 401) {
    // Redirect to authentication point if not authorized
    window.location.href = "/api/auth";
  }
  ...
}

Déclenchement de l'authentification Azure AD dans notre endpoint

Pour cette dernière partie, nous avons besoin d'un endpoint accessible de manière anonyme qui pourra permettre de déclencher le Challenge(AzureADDefaults.OpenIdScheme) en cas d'accès sans être authentifié. Cela provoque la redirection de l'utilisateur vers la mire de login Azure AD, rappelle cet endpoint (configuré en tant que ReplyUrl sur Azure AD) et permet la création du cookie d'authentification. Il ne nous reste plus qu'à rediriger notre utilisateur vers notre SPA.

[Route("api/[controller]")]
[ApiController]
public class AuthController : ControllerBase
{
    [HttpGet]
    [AllowAnonymous]
    public IActionResult Login(CancellationToken cancellationToken = default)
    {
        if (User.Identity.IsAuthenticated) // = Is User authenticated by Azure AD
        {
            try
            {
                // Check if user access is legitimate on this website, and throw UnauthorizedAccessException if not
            }
            catch (UnauthorizedAccessException)
            {
                return Unauthorized(new { Message = "You are not authorized to access to this platform." });
            }
        }
        else
        {
            // Trigger Azure AD authentication (using redirection)
            return Challenge(AzureADDefaults.OpenIdScheme);
        }
        // Redirect to home page is successfully authenticated
        return Redirect("/");
    }
}

Commentaires additionnels

Je fus assez surpris de ne pas réussir à trouver de mise en place simple d'une authentification par cookie avec Azure AD, une SPA et aspnetcore.

Un point important à noter est que ce scénario nécessite que la SPA soit hébergée sur le même domaine que l'API (Ce qui est le fonctionnement par défaut lors de la création d'un projet via dotnet new react ou dotnet new angular), étant donné que les Cookies ont une portée par Domain et Path.

J'espère que cet article vous aura aidé ou vous aura permis de découvrir une autre manière de sécuriser votre API avec Azure AD.

N'hésitez pas à manifester vos remarques ou toute opinion dans les commentaires ou sur mon compte Twitter @vivienfabing. Que le code soit avec vous !

Note : vous pourrez trouver un exemple sur mon GitHub.

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus