Loupe

Utiliser le Device Flow OAuth 2.0 pour authentifier les utilisateurs dans une application desktop

Depuis quelques années, OpenID Connect est devenu un des moyens les plus courants d'authentifier les utilisateurs dans une application web. Mais quand on veut l'utiliser dans une application desktop, ça se complique un peu…

Authorization code flow

OpenID Connect est une couche d'authentification basée sur le protocole OAuth 2.0, ce qui signifie qu'on doit utiliser l'un des flux d'autorisation d'OAuth 2.0. Jusqu'à récemment, il y avait grosso modo deux flux possibles utilisables dans une application desktop pour authentifier un utilisateur :

Le flux ROPC est très facile à utiliser (il s'agit simplement d'échanger le login et mot de passe de l'utilisateur contre un token), mais il nécessite un haut niveau de confiance envers l'application cliente, puisqu'elle manipule directement les identifiants de l'utilisateur. Ce flux est désormais prohibé par les best practices de sécurité OAuth 2.0.

Le flux Authorization Code est un peu plus complexe, mais présente l'avantage que l'application cliente ne voit jamais le mot de passe de l'utilisateur. Le problème est qu'il requiert une navigation web avec une redirection vers l'application cliente, ce qui est assez laborieux à mettre en place dans une application desktop. Il y a des moyens de le faire, mais aucun n'est parfait. En voici deux assez courants :

  • Ouvrir la page d'autorisation dans une WebView, et intercepter la navigation vers l'URL de redirection pour obtenir le code d'autorisation. Pas idéal, car l'application pourrait obtenir les identifiants de l'utilisateur depuis la WebView (du moins sur certaines plateformes), et cela nécessite que la WebView supporte l'interception de la navigation (sans doute pas possible sur toutes les plateformes).
  • Ouvrir la page d'autorisation dans le navigateur par défaut, et utiliser un protocole d'application (par exemple myapp://auth) associé à l'application cliente pour l'URL de redirection. Malheureusement, une mise à jour récente de Chrome a rendu cette approche peu pratique, car désormais le navigateur demande systématiquement à l'utilisateur s'il veut ouvrir l'URL dans l'application cliente.

De plus, afin de se protéger contre certains vecteurs d'attaque, il est recommandé d'utiliser l'extension PKCE avec le flux Authorization Code, ce qui rend l'implémentation un peu plus complexe.

Enfin, de nombreux fournisseurs d'identité requièrent que le client (pas l'utilisateur) s'authentifie avec un "client secret" lors de l'appel au endpoint de token, bien que ce ne soit pas requis par la spécification pour les clients publics. C'est problématique pour une application desktop, car elle sera probablement installée sur de nombreuses machines, et un utilisateur pourrait facilement en extraire le "client secret", qui du coup ne serait plus du tout secret…

Une meilleure option : le Device Flow

Face à ces difficultés, le Device Flow (ou, plus formellement, le Device Authorization Grant) peut être une alternative intéressante. C'est un flux d'autorisation ajouté assez récemment à OAuth 2.0 (le premier draft a été publié en 2016), qui a été conçu pour les appareils connectés qui n'ont pas de navigateur ou ont des capacités limitées de saisie utilisateur.

Comment s'authentifier sur un appareil qui n'a pas de clavier ? Facile : on le fait sur un autre appareil ! Le principe est le suivant : quand l'utilisateur doit être authentifié, l'appareil affiche une URL et un code (ou un QR Code pour éviter d'avoir à copier l'URL), et commence à demander à intervalle régulier au fournisseur d'identité (IdP) si l'authentification est terminée. Pendant ce temps, l'utilisateur ouvre l'URL dans un navigateur sur son smartphone ou son ordinateur, saisit le code affiché sur l'appareil, et se connecte à son compte. Quand c'est terminé, l'IdP répond à l'appareil que l'authentification est terminée, et lui envoie un token : le flux est terminé. Ce diagramme tiré de la documentation Azure AD explique assez bien le fonctionnement de ce flux :

Device flow

Quand on y réfléchit, cette approche est assez simple, et moins alambiquée que les flux basés sur des redirections web (Authorization Code Flow et Implicit Flow). Mais vous vous demandez peut-être, qu'est-ce que ça a à voir avec les applications desktops ? Eh bien, même si le Device Flow a été conçu pour des appareils aux capacités de saisie limitées, rien n'empêche de l'utiliser sur un vrai ordinateur. Comme on l'a vu plus haut, les flux basés sur des redirections ne sont pas très adaptés aux applications non web ; le Device Flow se débarrasse de ce problème.

En pratique, l'application cliente peut directement ouvrir la page d'authentification dans le navigateur, avec le code en paramètre, de façon à ce que l'utilisateur n'ait pas besoin de les copier. Cela rend le flux plus rapide et plus pratique pour l'utilisateur : il a juste besoin de s'identifier, donner son consentement pour l'application, et c'est fini. Bien sûr, si l'utilisateur était déjà authentifé sur l'IdP et avait déjà donné son consentement, le flux se termine immédiatement.

Le Device Flow n'est pas encore très largement utilisé dans les applications desktop, mais on peut déjà le voir en action dans la CLI Azure, quand on exécute la commande az login.

Une implémentation basique

Bon, ce billet a été un peu abstrait jusqu'ici, alors écrivons maintenant un peu de code ! On va créer une simple application console qui s'authentifie avec le Device Flow.

Dans cet exemple, on utilisera Azure AD comme fournisseur d'identité, parce que c'est simple et que ça ne demande pas un setup compliqué (on pourrait bien sûr utiliser n'importe quel autre IdP qui supporte Device Flow, comme Auth0, Okta, ou encore un IdP sur mesure basé sur IdentityServer ou OpenIddict). Allons dans le portail Azure, dans la section Azure Active Directory, puis sur la page Inscriptions des applications. Créons une application, donnons lui n'importe quel nom, et choisissons Comptes dans cet annuaire organisationnel uniquement pour les types de compte pris en charge (cela fonctionnerait aussi en multi-tenant, mais gardons les choses simples pour l'instant). Saisissons une URL de redirection pour un client public ; ça ne devrait pas être nécessaire pour le Device Flow, et elle ne sera pas utilisée, mais l'authentification échouera si elle n'est pas définie… Une des bizarreries d'AzureAD, je suppose.

App registration

Allons maintenant sur la page Authentification de l'application, et activons l'option Considérer l’application comme un client public.

Public client

C'est tout pour l'enregistrement de l'application. Il faut juste noter les valeurs suivantes dans la page Vue d'ensemble de l'application :

  • Application ID (ou client ID dans la terminologie OAuth)
  • Directory ID (ou tenant ID ; c'est notre tenant Azure AD)

Dans notre programme, la première étape est d'envoyer à l'IdP une demande d'autorisation pour démarrer le flux. Il y a un endpoint dédié pour le device flow, qui n'est pas indiqué dans le document de découverte OpenID Connect d'Azure AD, mais qu'on peut trouver dans la documentation. On envoie à ce endpoint le client ID de notre application, ainsi que les scopes demandés ; en l'occurrence, on va juste demander openid, profile et offline_access (pour obtenir un refresh token), mais dans un scénario réel on aurait sans doute aussi besoin d'un scope d'API.

private const string TenantId = "<your tenant id>";
private const string ClientId = "<your client id>";

private static async Task<DeviceAuthorizationResponse> StartDeviceFlowAsync(HttpClient client)
{
    string deviceEndpoint = $"https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/devicecode";
    var request = new HttpRequestMessage(HttpMethod.Post, deviceEndpoint)
    {
        Content = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            ["client_id"] = ClientId,
            ["scope"] = "openid profile offline_access"
        })
    };
    var response = await client.SendAsync(request);
    response.EnsureSuccessStatusCode();
    var json = await response.Content.ReadAsStringAsync();
    return JsonSerializer.Deserialize<DeviceAuthorizationResponse>(json);
}

private class DeviceAuthorizationResponse
{
    [JsonPropertyName("device_code")]
    public string DeviceCode { get; set; }

    [JsonPropertyName("user_code")]
    public string UserCode { get; set; }

    [JsonPropertyName("verification_uri")]
    public string VerificationUri { get; set; }

    [JsonPropertyName("expires_in")]
    public int ExpiresIn { get; set; }

    [JsonPropertyName("interval")]
    public int Interval { get; set; }
}

Appelons cette méthode, et ouvrons la verification_uri dans le navigateur. L'utilisateur devra saisir le user_code dans la page d'autorisation.

using var client = new HttpClient();
var authorizationResponse = await StartDeviceFlowAsync(client);
Console.WriteLine("Please visit this URL: " + authorizationResponse.VerificationUri);
Console.WriteLine("And enter the following code: " + authorizationResponse.UserCode);
OpenWebPage(authorizationResponse.VerificationUri);

Cela ouvre la page suivante:

Enter user code

Remarque : les spécifications du Device Flow mentionnent une propriété optionnelle verification_uri_complete dans la réponse de la demande d'autorisation, qui inclue le user_code. Malheureusement, cette propriété n'est pas supportée par Azure AD, l'utilisateur doit donc saisir le code manuellement.

Pendant que l'utilisateur saisit le code et s'authentifie, on demande régulièrement à l'IdP de nous envoyer un token. Il faut spécifier urn:ietf:params:oauth:grant-type:device_code comme grant_type, et fournir le device_code qu'on a reçu dans la réponse initiale.

var tokenResponse = await GetTokenAsync(client, authorizationResponse);
Console.WriteLine("Access token: ");
Console.WriteLine(tokenResponse.AccessToken);
Console.WriteLine("ID token: ");
Console.WriteLine(tokenResponse.IdToken);
Console.WriteLine("refresh token: ");
Console.WriteLine(tokenResponse.IdToken);

...

private static async Task<TokenResponse> GetTokenAsync(HttpClient client, DeviceAuthorizationResponse authResponse)
{
    string tokenEndpoint = $"https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token";

    // Poll until we get a valid token response or a fatal error
    int pollingDelay = authResponse.Interval;
    while (true)
    {
        var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint)
        {
            Content = new FormUrlEncodedContent(new Dictionary<string, string>
            {
                ["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code",
                ["device_code"] = authResponse.DeviceCode,
                ["client_id"] = ClientId
            })
        };
        var response = await client.SendAsync(request);
        var json = await response.Content.ReadAsStringAsync();
        if (response.IsSuccessStatusCode)
        {
            return JsonSerializer.Deserialize<TokenResponse>(json);
        }
        else
        {
            var errorResponse = JsonSerializer.Deserialize<TokenErrorResponse>(json);
            switch(errorResponse.Error)
            {
                case "authorization_pending":
                    // Not complete yet, wait and try again later
                    break;
                case "slow_down":
                    // Not complete yet, and we should slow down the polling
                    pollingDelay += 5;                            
                    break;
                default:
                    // Some other error, nothing we can do but throw
                    throw new Exception(
                        $"Authorization failed: {errorResponse.Error} - {errorResponse.ErrorDescription}");
            }

            await Task.Delay(TimeSpan.FromSeconds(pollingDelay));
        }
    }
}

private class TokenErrorResponse
{
    [JsonPropertyName("error")]
    public string Error { get; set; }

    [JsonPropertyName("error_description")]
    public string ErrorDescription { get; set; }
}

private class TokenResponse
{
    [JsonPropertyName("access_token")]
    public string AccessToken { get; set; }

    [JsonPropertyName("id_token")]
    public string IdToken { get; set; }

    [JsonPropertyName("refresh_token")]
    public string RefreshToken { get; set; }

    [JsonPropertyName("token_type")]
    public string TokenType { get; set; }

    [JsonPropertyName("expires_in")]
    public int ExpiresIn { get; set; }

    [JsonPropertyName("scope")]
    public string Scope { get; set; }
}

Tant que l'utilisateur n'a pas fini de s'authentifier, le endpoint de token nous renvoie une réponse d'erreur indiquant authorization_pending. Quand il a fini, le prochain appel nous renvoie une réponse avec le token demandé.

Quand l'access_token expire, on peut utiliser le refresh_token pour en obtenir un nouveau, comme décrit dans les spécifications.

Conclusion

Comme vous pouvez le voir, le Device Flow est assez facile à implémenter ; il est simple, sans mécanisme de redirection. Sa simplicité contribue à le rendre sûr, il y a peu de vecteurs d'attaque contre ce flux. À mon avis, c'est le flux d'autorisation idéal pour des applications desktop ou console.

Vous pouvez trouver le code complet de cet article dans ce dépôt Github.

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus