ASP.NET Core OpenIdConnect et liens dans des documents MS Office

Si vous utilisez le package standard Microsoft.AspNetCore.Authentication.OpenIdConnect pour gérer l’authentification d’une application web ASP.NET Core, vous devriez pouvoir observer un problème intéressant si vous tentez d’accéder à une page protégée (requérant un utilisateur authentifié) à partir d’un document ouvert dans un programme MS Office tel que Word.

Pour une introduction plus complète sur le package OpenIdConnect, indépendamment du problème que nous traitons ici, voici un excellent blog: An introduction to OpenID Connect in ASP.NET Core

Le problème: la première authentification échoue

Si vous n’êtes pas déjà authentifié sur le site web, le symptome typique est que lorsque vous cliquez sur un lien dans un document Office, une page d’erreur s’affiche dans le navigateur juste après l’authentification auprès du serveur d’authentification (mais juste avant que le site ne vous considère authentifié). L’origine est typiquement une exception avec le message cryptique « Correlation failed ».

Dans certains cas, un autre symptome est que lorsque vous cliquez sur un lien dans le document Office, la page du lien s’ouvre bien dans le navigateur, en plus d’un autre onglet contenant une page du serveur d’authentification (soit une erreur, soit l’écran de connexion typiquement).

Pour que le problème se manifeste, il se peut qu’il faille que le document Office ouvert soit en mode Edition car ce problème résulte d’un mécanisme lié mode d’édition des documents Office.

Ce problème est décrit par Microsoft: Office Products Troubleshooting/You are redirected to a logon page or an error page, or you are prompted for authentication information when you click a hyperlink to a SSO Web site in an Office document.

Pour résumer, les liens ouverts via MS Office ne sont pas directement ouverts dans le navigateur. Ils passent par un composant Hlink.dll (Microsoft Hyperlink Library). Ce composant tente d’abord de communiquer avec le serveur de la ressource ciblée afin d’ouvrir le document ciblé en mode Edition. Cet échange serait lié au fait que MS Office soit « Web-aware », qui semble être une « norme » très propriétaire et très spécifique à MS Office (et peut-être à d’autres programmes IBM d’après une rapide recherche).

Si cet échange « Web-aware » est infructueux, c’est à dire si le programme Office ne « comprend » pas la réponse, alors le lien sera ouvert dans un navigateur.
Le problème est que la réponse la plus conventionnelle lorsque le serveur souhaite déclencher l’authentification est une redirection HTTP (code 302) vers le serveur d’authentification. Cette réponse (redirection) contient également des cookies qui font partie de cette phase d’authentification.

Office, au lieu d’ouvrir l’URL originale dans le navigateur, va ouvrir directement l’URL de redirection. C’est là que se situe ce que je qualifierai de bug dans Office, mais Microsoft n’est pas de cet avis d’après le document mentionné. L’authentification sur le serveur d’authentification va bien se passer, mais la redirection de retour vers votre application web va échouer car votre navigateur ne va pas soumettre les cookies reçus dans la première requête – qu’il n’a pas envoyé (ils ont été retourné au programme Office, pas au navigateur). L’un des cookies sert à corréler la dernière redirection à la première. C’est ainsi que le site ASP.NET vérifie que le résultat de l’authentification correspond bien à une demande précédente de sa part. Le cookie, absent du navigateur, n’étant pas envoyé, cela entraîne ce fameux message « Correlation failed » (noter que ce message peut être causé par beaucoup d’autres scénarios, souvent liés plus ou moins aux cookies ou au fait que l’application soit composée de plusieurs instances derrière un load balancer – ce n’est pas le sujet de cette page).

La solution

Appelons-la plutôt un contournement de ce ~~tte « fonctionnalité »~~bug de MS Office: il y en a plusieurs et celle qui me semble être la meilleure est donnée dans les dernières lignes du document de Microsoft:

For an HTTP request […], issue a client-side redirect response instead of a server-side redirect response. For example, send an HTTP script or a META REFRESH tag instead of an HTTP 302 response.

Ce qui n’est pas forcément évident en lisant cela, c’est que le programme MS Office (Word par exemple) va d’abord tenter de résoudre le lien et va ignorer la réponse (car ne saura pas l’interpréter), puis va finalement ouvrir le lien original dans le navigateur. Ce lien va ensuite déclencher la redirection qui sera suivie par le navigateur pour initier l’authentification.

Voici un exemple simple de comment le faire avec la librairie OpenIdConnect (dans sa version 3.1.4 pour ASP.NET Core 3.1). Une petite optimisation consiste à n’activer ce contournement que si un programme Office est détecté grâce à l’entête HTTP User-Agent.

Nous exploitons le point d’extension OpenIdConnectEvents.OnRedirectToIdentityProvider qui est invoqué peu avant la redirection vers le serveur d’authentification (heureusement les cookies ont déjà été générés dans la réponse):

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

public class Startup
{
    // [...]

    public void ConfigureServices(IServiceCollection services)
    {
        // [...]

        services.AddAuthentication(options =>
        {
            options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
        })
        .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
        .AddOpenIdConnect(openIdConnectOptions =>
        {
            // [...]
            openIdConnectOptions.Events.OnRedirectToIdentityProvider = OpenIdConnectHookForMsOffice.OnRedirectToIdentityProvider;
        });

        // [...]
    }

    // [...]
}
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

static class OpenIdConnectHookForMsOffice
{
    static bool ShouldRedirectClientSide(HttpRequest request)
    {
        if (request.Headers.TryGetValue("User-Agent", out StringValues headerValues))
        {
            string useragent = headerValues;
            if (useragent.Contains("Word") ||
                useragent.Contains("Excel") ||
                useragent.Contains("PowerPoint") ||
                useragent.Contains("ms-office"))
            {
                return true;   
            }
        }
        return false;    
    }

    static Task OnRedirectToIdentityProvider(RedirectContext ctx)
    {
        if (ShouldRedirectClientSide(ctx.Request) &&
            !string.IsNullOrEmpty(ctx.ProtocolMessage.IssuerAddress))
        {
            // Exact copy of OpenIdConnectHandler.cs except for Response redirect.
            // See https://github.com/dotnet/aspnetcore/blob/35628a67800a3e269eb375989d2fffa9d67b8dbf/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectHandler.cs#L426

            OpenIdConnectMessage message = ctx.ProtocolMessage;
            AuthenticationProperties properties = ctx.Properties;

            if (!string.IsNullOrEmpty(message.State))
            {   
                properties.Items[OpenIdConnectDefaults.UserstatePropertiesKey] = message.State;
            }

            properties.Items.Add(OpenIdConnectDefaults.RedirectUriForCodePropertiesKey, message.RedirectUri);
            message.State = ctx.Options.StateDataFormat.Protect(properties);

            string redirectUrl = message.CreateAuthenticationRequestUrl();
            ctx.Response.Headers.Add("Refresh", "0;url=" + redirectUrl);
            ctx.HandleResponse();
        }

        return Task.CompletedTask;
    }
}

Noter que l’on est très dépendant de détails d’implémentation interne de OpenIdConnectHandler. En fonction de la version effectivement utilisée du package OpenIdConnect dans votre application (notre exemple correspond à la version 3.1.4 pour ASP.NET Core 3.1), il peut être judicieux de vérifier qu’il n’y a pas eu d’évolution dans cette partie de son code (cf. lien vers le code source sur GitHub dans l’exemple de code ci-dessus). Sur ce point, on ne peut que saluer l’adoption du mouvement open source par Microsoft. Un ticket de 2017 existe d’ailleurs sur GitHub pour demander à Microsoft l’ajout d’un point d’extension pour supporter une stratégie de redirection de manière plus élégante, malheureusement cette demande a été ignorée.

Le code recopié à partir de OpenIdConnectHandler devrait être identique jusqu’à la redirection: au lieu d’une redirection standard (HTTP 302), on ajoute une entête HTTP « Refresh ». Celle-ci est supportée par la plupart des navigateurs (vérifiez-le ici). Cette entête est un peu spéciale dans le sens où elle n’est pas spécifiée au niveau HTTP mais au niveau HTML, en tant que pragma directive, avec la balise <meta http-equiv="refresh" content="0;URL='https://...'" /> (cf. w3.org). La directive http-equiv="refresh" signifie littéralement que cette balise est équivalente à une entête HTTP « refresh » (non sensible à la casse). Raison pour laquelle nous pouvons nous contenter d’une simple entête HTTP sans body et sans cette balise.
Un Refresh avec un délai de 0 sera interprété par les navigateurs de façon équivalente à une redirection de type HTTP 302.

Solutions alternatives

OpenIdConnectRedirectBehavior.FormPost

Une alternative est d’activer l’option standard OpenIdConnectOptions.AuthenticationMethod=OpenIdConnectRedirectBehavior.FormPost (au lieu de la valeur par défaut RedirectGet).

Cependant lors de mes tests, cela entraînait l’ouverture de deux onglets de navigateur par Word: un onglet avec la bonne ressource ciblée, et un autre onglet contenant une page d’erreur liée encore une fois à l’authentication.

Base de registre

Autre alternative peu séduisante: modifier la base de registre sur le poste utilisateur. C’est également documenté dans le document de Microsoft.

Retourner une page vide aux programmes Office

On aurait pu utiliser un middleware qui retournerait une réponse vide si l’entête User-Agent correspond à un programme Office. On devrait observer le même résultat.
La solution donnée me semble plus fiable car :

  • Cela fonctionnerait même sans l’optimisation basée sur l’entete User-Agent (le Refresh fonctionnera même depuis un navigateur sans passer par un programme Office).
  • Je pense qu’il y a davantage de chances d’évolutions du côté d’Office que du côté du package OpenIdConnect: en degré de « hack », la page blanche retournée me semble plus « hacky » que l’entete Refresh avec une URL qui fonctionne (même si elle ne sera pas utilisée dans l’état actuel des choses).
  • Enfin, la solution donnée correspond à l’une des solutions documentées par Microsoft: cela rejoint le deuxième point, on peut espérer que Microsoft en tienne compte en cas d’évolutions dans Office.