Tutorial: Inicio de sesión por los usuarios y llamada a Microsoft Graph API en una aplicación de escritorio de Electron

En este tutorial creará una aplicación de escritorio de Electron con la que los usuarios inician sesión y que llama a Microsoft Graph mediante el flujo de código de autorización con PKCE. La aplicación de escritorio que cree usa la biblioteca de autenticación de Microsoft (MSAL) para Node.js.

Siga los pasos de este tutorial para lo siguiente:

  • Registrar la aplicación en Azure Portal
  • Creación de un proyecto de aplicación de escritorio de Electron
  • Incorporación de la lógica de autenticación a la aplicación
  • Incorporación de un método para llamar a una API web
  • Incorporación de detalles del registro de la aplicación
  • Prueba de la aplicación

Requisitos previos

Registro de la aplicación

En primer lugar, complete los pasos descritos en Registro de una aplicación en la plataforma de identidad de Microsoft para registrar la aplicación.

Use la siguiente configuración para el registro de la aplicación:

  • Nombre: ElectronDesktopApp (sugerido)
  • Tipos de cuenta admitidos: Las cuentas de este directorio organizativo (inquilino único)
  • Tipo de plataforma: Aplicaciones móviles y de escritorio
  • URI de redirección: http://localhost

Creación del proyecto

Cree una carpeta para hospedar la aplicación, por ejemplo, ElectronDesktopApp.

  1. En primer lugar, cambie al directorio del proyecto en el terminal y, a continuación, ejecute los siguientes comandos de npm:

    npm init -y
    npm install --save @azure/msal-node @microsoft/microsoft-graph-client isomorphic-fetch bootstrap jquery popper.js
    npm install --save-dev electron@20.0.0
    
  2. A continuación, cree una carpeta llamada App. Dentro de esta carpeta, cree un archivo denominado index.html que sirva como interfaz de usuario. Agréguele el siguiente código:

    <!DOCTYPE html>
    <html lang="en">
    
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
        <meta http-equiv="Content-Security-Policy" content="script-src 'self'" />
        <title>MSAL Node Electron Sample App</title>
    
        <!-- adding Bootstrap 4 for UI components  -->
        <link rel="stylesheet" href="../node_modules/bootstrap/dist/css/bootstrap.min.css">
    </head>
    
    <body>
        <nav class="navbar navbar-expand-lg navbar-dark bg-primary">
            <a class="navbar-brand">Microsoft identity platform</a>
            <div class="btn-group ml-auto dropleft">
                <button type="button" id="signIn" class="btn btn-secondary" aria-expanded="false">
                    Sign in
                </button>
                <button type="button" id="signOut" class="btn btn-success" hidden aria-expanded="false">
                    Sign out
                </button>
            </div>
        </nav>
        <br>
        <h5 class="card-header text-center">Electron sample app calling MS Graph API using MSAL Node</h5>
        <br>
        <div class="row" style="margin:auto">
            <div id="cardDiv" class="col-md-6" style="display:none; margin:auto">
                <div class="card text-center">
                    <div class="card-body">
                        <h5 class="card-title" id="WelcomeMessage">Please sign-in to see your profile and read your mails
                        </h5>
                        <div id="profileDiv"></div>
                        <br>
                        <br>
                        <button class="btn btn-primary" id="seeProfile">See Profile</button>
                    </div>
                </div>
            </div>
        </div>
    
        <!-- importing bootstrap.js and supporting js libraries -->
        <script src="../node_modules/jquery/dist/jquery.js"></script>
        <script src="../node_modules/popper.js/dist/umd/popper.js"></script>
        <script src="../node_modules/bootstrap/dist/js/bootstrap.js"></script>
    
        <!-- importing app scripts | load order is important -->
        <script src="./renderer.js"></script>
    
    </body>
    
    </html>
    
  3. Después, cree un archivo llamado main.js y agregue el código siguiente:

    /*
     * Copyright (c) Microsoft Corporation. All rights reserved.
     * Licensed under the MIT License.
     */
    
    const path = require("path");
    const { app, ipcMain, BrowserWindow } = require("electron");
    
    const AuthProvider = require("./AuthProvider");
    const { IPC_MESSAGES } = require("./constants");
    const { protectedResources, msalConfig } = require("./authConfig");
    const getGraphClient = require("./graph");
    
    let authProvider;
    let mainWindow;
    
    function createWindow() {
        mainWindow = new BrowserWindow({
            width: 800,
            height: 600,
            webPreferences: { preload: path.join(__dirname, "preload.js") },
        });
    
        authProvider = new AuthProvider(msalConfig);
    }
    
    app.on("ready", () => {
        createWindow();
        mainWindow.loadFile(path.join(__dirname, "./index.html"));
    });
    
    app.on("window-all-closed", () => {
        app.quit();
    });
    
    app.on('activate', () => {
        // On OS X it's common to re-create a window in the app when the
        // dock icon is clicked and there are no other windows open.
        if (BrowserWindow.getAllWindows().length === 0) {
            createWindow();
        }
    });
    
    
    // Event handlers
    ipcMain.on(IPC_MESSAGES.LOGIN, async () => {
        const account = await authProvider.login();
    
        await mainWindow.loadFile(path.join(__dirname, "./index.html"));
        
        mainWindow.webContents.send(IPC_MESSAGES.SHOW_WELCOME_MESSAGE, account);
    });
    
    ipcMain.on(IPC_MESSAGES.LOGOUT, async () => {
        await authProvider.logout();
    
        await mainWindow.loadFile(path.join(__dirname, "./index.html"));
    });
    
    ipcMain.on(IPC_MESSAGES.GET_PROFILE, async () => {
        const tokenRequest = {
            scopes: protectedResources.graphMe.scopes
        };
    
        const tokenResponse = await authProvider.getToken(tokenRequest);
        const account = authProvider.account;
    
        await mainWindow.loadFile(path.join(__dirname, "./index.html"));
    
        const graphResponse = await getGraphClient(tokenResponse.accessToken)
            .api(protectedResources.graphMe.endpoint).get();
    
        mainWindow.webContents.send(IPC_MESSAGES.SHOW_WELCOME_MESSAGE, account);
        mainWindow.webContents.send(IPC_MESSAGES.SET_PROFILE, graphResponse);
    });
    

En el fragmento de código anterior se inicializa un objeto de ventana principal de Electron y se crean algunos controladores de eventos para las interacciones con la ventana de Electron. También se importan parámetros de configuración, se crean instancias de la clase authProvider para controlar el inicio de sesión, el cierre de sesión y la adquisición de tokens, y se llama a Microsoft Graph API.

  1. En la misma carpeta (App), cree otro archivo denominado renderer.js y agregue el código siguiente:

    // Copyright (c) Microsoft Corporation. All rights reserved.
    // Licensed under the MIT License
    
    /**
     * The renderer API is exposed by the preload script found in the preload.ts
     * file in order to give the renderer access to the Node API in a secure and 
     * controlled way
     */
    const welcomeDiv = document.getElementById('WelcomeMessage');
    const signInButton = document.getElementById('signIn');
    const signOutButton = document.getElementById('signOut');
    const seeProfileButton = document.getElementById('seeProfile');
    const cardDiv = document.getElementById('cardDiv');
    const profileDiv = document.getElementById('profileDiv');
    
    window.renderer.showWelcomeMessage((event, account) => {
        if (!account) return;
    
        cardDiv.style.display = 'initial';
        welcomeDiv.innerHTML = `Welcome ${account.name}`;
        signInButton.hidden = true;
        signOutButton.hidden = false;
    });
    
    window.renderer.handleProfileData((event, graphResponse) => {
        if (!graphResponse) return;
    
        console.log(`Graph API responded at: ${new Date().toString()}`);
        setProfile(graphResponse);
    });
    
    // UI event handlers
    signInButton.addEventListener('click', () => {
        window.renderer.sendLoginMessage();
    });
    
    signOutButton.addEventListener('click', () => {
        window.renderer.sendSignoutMessage();
    });
    
    seeProfileButton.addEventListener('click', () => {
        window.renderer.sendSeeProfileMessage();
    });
    
    const setProfile = (data) => {
        if (!data) return;
        
        profileDiv.innerHTML = '';
    
        const title = document.createElement('p');
        const email = document.createElement('p');
        const phone = document.createElement('p');
        const address = document.createElement('p');
    
        title.innerHTML = '<strong>Title: </strong>' + data.jobTitle;
        email.innerHTML = '<strong>Mail: </strong>' + data.mail;
        phone.innerHTML = '<strong>Phone: </strong>' + data.businessPhones[0];
        address.innerHTML = '<strong>Location: </strong>' + data.officeLocation;
    
        profileDiv.appendChild(title);
        profileDiv.appendChild(email);
        profileDiv.appendChild(phone);
        profileDiv.appendChild(address);
    }
    

Los métodos del representador se exponen mediante el script de precarga que se encuentra en el archivo preload.js para proporcionar al representador acceso a Node API de forma segura y controlada

  1. Una vez hecho esto, cree un nuevo archivo preload.js y agregue el siguiente código:

    // Copyright (c) Microsoft Corporation. All rights reserved.
    // Licensed under the MIT License
    
    const { contextBridge, ipcRenderer } = require('electron');
    
    /**
     * This preload script exposes a "renderer" API to give
     * the Renderer process controlled access to some Node APIs
     * by leveraging IPC channels that have been configured for
     * communication between the Main and Renderer processes.
     */
    contextBridge.exposeInMainWorld('renderer', {
        sendLoginMessage: () => {
            ipcRenderer.send('LOGIN');
        },
        sendSignoutMessage: () => {
            ipcRenderer.send('LOGOUT');
        },
        sendSeeProfileMessage: () => {
            ipcRenderer.send('GET_PROFILE');
        },
        handleProfileData: (func) => {
            ipcRenderer.on('SET_PROFILE', (event, ...args) => func(event, ...args));
        },
        showWelcomeMessage: (func) => {
            ipcRenderer.on('SHOW_WELCOME_MESSAGE', (event, ...args) => func(event, ...args));
        },
    });
    

Este script de precarga expone una API de representador para proporcionar al proceso de representador acceso controlado a algunos Node APIs mediante la aplicación de canales IPC configurados para la comunicación entre los procesos principales y del representador.

  1. Por último, cree un archivo denominado constants.js que almacenará las constantes de las cadenas para describir los eventos de la aplicación:

    /*
     * Copyright (c) Microsoft Corporation. All rights reserved.
     * Licensed under the MIT License.
     */
    
    const IPC_MESSAGES = {
        SHOW_WELCOME_MESSAGE: 'SHOW_WELCOME_MESSAGE',
        LOGIN: 'LOGIN',
        LOGOUT: 'LOGOUT',
        GET_PROFILE: 'GET_PROFILE',
        SET_PROFILE: 'SET_PROFILE',
    }
    
    module.exports = {
        IPC_MESSAGES: IPC_MESSAGES,
    }
    

Ahora tiene una interfaz gráfica de usuario e interacciones sencillas para su aplicación de Electron. Después de completar el resto del tutorial, la estructura de archivos y carpetas del proyecto será similar a la siguiente:

ElectronDesktopApp/
├── App
│   ├── AuthProvider.js
│   ├── constants.js
│   ├── graph.js
│   ├── index.html
|   ├── main.js
|   ├── preload.js
|   ├── renderer.js
│   ├── authConfig.js
├── package.json

Incorporación de la lógica de autenticación a la aplicación

En carpeta App, cree un archivo denominado AuthProvider.js. El archivo AuthProvider.jscontendrá una clase de proveedor de autenticación que controlará el inicio de sesión, el cierre de sesión, la adquisición de tokens, la selección de cuentas y las tareas de autenticación relacionadas mediante el nodo de MSAL. Agréguele el siguiente código:

/*
 * Copyright (c) Microsoft Corporation. All rights reserved.
 * Licensed under the MIT License.
 */

const { PublicClientApplication, InteractionRequiredAuthError } = require('@azure/msal-node');
const { shell } = require('electron');

class AuthProvider {
    msalConfig
    clientApplication;
    account;
    cache;

    constructor(msalConfig) {
        /**
         * Initialize a public client application. For more information, visit:
         * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-node/docs/initialize-public-client-application.md
         */
        this.msalConfig = msalConfig;
        this.clientApplication = new PublicClientApplication(this.msalConfig);
        this.cache = this.clientApplication.getTokenCache();
        this.account = null;
    }

    async login() {
        const authResponse = await this.getToken({
            // If there are scopes that you would like users to consent up front, add them below
            // by default, MSAL will add the OIDC scopes to every token request, so we omit those here
            scopes: [],
        });

        return this.handleResponse(authResponse);
    }

    async logout() {
        if (!this.account) return;

        try {
            /**
             * If you would like to end the session with AAD, use the logout endpoint. You'll need to enable
             * the optional token claim 'login_hint' for this to work as expected. For more information, visit:
             * https://video2.skills-academy.com/azure/active-directory/develop/v2-protocols-oidc#send-a-sign-out-request
             */
            if (this.account.idTokenClaims.hasOwnProperty('login_hint')) {
                await shell.openExternal(`${this.msalConfig.auth.authority}/oauth2/v2.0/logout?logout_hint=${encodeURIComponent(this.account.idTokenClaims.login_hint)}`);
            }

            await this.cache.removeAccount(this.account);
            this.account = null;
        } catch (error) {
            console.log(error);
        }
    }

    async getToken(tokenRequest) {
        let authResponse;
        const account = this.account || (await this.getAccount());

        if (account) {
            tokenRequest.account = account;
            authResponse = await this.getTokenSilent(tokenRequest);
        } else {
            authResponse = await this.getTokenInteractive(tokenRequest);
        }

        return authResponse || null;
    }

    async getTokenSilent(tokenRequest) {
        try {
            return await this.clientApplication.acquireTokenSilent(tokenRequest);
        } catch (error) {
            if (error instanceof InteractionRequiredAuthError) {
                console.log('Silent token acquisition failed, acquiring token interactive');
                return await this.getTokenInteractive(tokenRequest);
            }

            console.log(error);
        }
    }

    async getTokenInteractive(tokenRequest) {
        try {
            const openBrowser = async (url) => {
                await shell.openExternal(url);
            };

            const authResponse = await this.clientApplication.acquireTokenInteractive({
                ...tokenRequest,
                openBrowser,
                successTemplate: '<h1>Successfully signed in!</h1> <p>You can close this window now.</p>',
                errorTemplate: '<h1>Oops! Something went wrong</h1> <p>Check the console for more information.</p>',
            });

            return authResponse;
        } catch (error) {
            throw error;
        }
    }

    /**
     * Handles the response from a popup or redirect. If response is null, will check if we have any accounts and attempt to sign in.
     * @param response
     */
    async handleResponse(response) {
        if (response !== null) {
            this.account = response.account;
        } else {
            this.account = await this.getAccount();
        }

        return this.account;
    }

    /**
     * Calls getAllAccounts and determines the correct account to sign into, currently defaults to first account found in cache.
     * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-common/docs/Accounts.md
     */
    async getAccount() {
        const currentAccounts = await this.cache.getAllAccounts();

        if (!currentAccounts) {
            console.log('No accounts detected');
            return null;
        }

        if (currentAccounts.length > 1) {
            // Add choose account code here
            console.log('Multiple accounts detected, need to add choose account code.');
            return currentAccounts[0];
        } else if (currentAccounts.length === 1) {
            return currentAccounts[0];
        } else {
            return null;
        }
    }
}

module.exports = AuthProvider;

En el fragmento de código anterior, primero se pasó un objeto de configuración (msalConfig) para inicializar el nodo de MSAL PublicClientApplication. A continuación, se expusieron los métodos login, logout y getToken a los que llamará el módulo principal (main.js). En login y getToken se adquieren tokens de identificador y de acceso mediante la API pública acquireTokenInteractive del nodo de MSAL.

Adición del SDK de Microsoft Graph

Cree un archivo denominado graph.js. El archivo graph.js contendrá una instancia del cliente del SDK de Microsoft Graph para facilitar el acceso a los datos en Microsoft Graph API, mediante el token de acceso obtenido por el nodo MSAL:

const { Client } = require('@microsoft/microsoft-graph-client');
require('isomorphic-fetch');

/**
 * Creating a Graph client instance via options method. For more information, visit:
 * https://github.com/microsoftgraph/msgraph-sdk-javascript/blob/dev/docs/CreatingClientInstance.md#2-create-with-options
 * @param {String} accessToken
 * @returns
 */
const getGraphClient = (accessToken) => {
    // Initialize Graph client
    const graphClient = Client.init({
        // Use the provided access token to authenticate requests
        authProvider: (done) => {
            done(null, accessToken);
        },
    });

    return graphClient;
};

module.exports = getGraphClient;

Agregar detalles del registro de la aplicación

Cree un archivo de entorno para almacenar los detalles de registro de la aplicación que se usarán al adquirir tokens. Para ello, cree un archivo denominado authConfig.js dentro de la carpeta raíz del ejemplo (ElectronDesktopApp) y agregue el código siguiente:

/*
 * Copyright (c) Microsoft Corporation. All rights reserved.
 * Licensed under the MIT License.
 */

const { LogLevel } = require("@azure/msal-node");

/**
 * Configuration object to be passed to MSAL instance on creation.
 * For a full list of MSAL.js configuration parameters, visit:
 * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-node/docs/configuration.md
 */
const AAD_ENDPOINT_HOST = "Enter_the_Cloud_Instance_Id_Here"; // include the trailing slash

const msalConfig = {
    auth: {
        clientId: "Enter_the_Application_Id_Here",
        authority: `${AAD_ENDPOINT_HOST}Enter_the_Tenant_Info_Here`,
    },
    system: {
        loggerOptions: {
            loggerCallback(loglevel, message, containsPii) {
                console.log(message);
            },
            piiLoggingEnabled: false,
            logLevel: LogLevel.Verbose,
        },
    },
};

/**
 * Add here the endpoints and scopes when obtaining an access token for protected web APIs. For more information, see:
 * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/resources-and-scopes.md
 */
const GRAPH_ENDPOINT_HOST = "Enter_the_Graph_Endpoint_Here"; // include the trailing slash

const protectedResources = {
    graphMe: {
        endpoint: `${GRAPH_ENDPOINT_HOST}v1.0/me`,
        scopes: ["User.Read"],
    }
};


module.exports = {
    msalConfig: msalConfig,
    protectedResources: protectedResources,
};

Rellene estos detalles con los valores que se obtienen del portal de registro de aplicaciones de Azure:

  • Enter_the_Tenant_Id_here debe ser una de las siguientes:
    • Si la aplicación admite cuentas de este directorio organizativo, reemplace este valor por los valores de Id. de inquilino o Nombre de inquilino. Por ejemplo, contoso.microsoft.com.
    • Si la aplicación admite cuentas de cualquier directorio organizativo, reemplace este valor por organizations.
    • Si la aplicación admite cuentas de cualquier directorio organizativo y cuentas Microsoft personales, reemplace este valor por common.
    • Para restringir la compatibilidad a solo cuentas de Microsoft personales, reemplace este valor por consumers.
  • Enter_the_Application_Id_Here: el identificador de la aplicación (cliente) de la aplicación que registró.
  • Enter_the_Cloud_Instance_Id_Here: Instancia en la nube de Azure en la que se registra la aplicación.
    • En el caso de la nube principal (o global) de Azure, escriba https://login.microsoftonline.com/.
    • En el caso de las nubes nacionales (por ejemplo, China), puede encontrar los valores adecuados en las nubes nacionales.
  • Enter_the_Graph_Endpoint_Here es la instancia de Microsoft Graph API con la que la aplicación debe comunicarse.
    • En el caso del punto de conexión de Microsoft Graph API global, reemplace ambas instancias de esta cadena por https://graph.microsoft.com/.
    • Para obtener información sobre los puntos de conexión en las implementaciones de nubes nacionales, consulte Implementaciones de nube nacionales en la documentación de Microsoft Graph.

Prueba de la aplicación

Ha terminado la creación de la aplicación y ya está listo para iniciar la aplicación de escritorio de Electron y probar la funcionalidad de la aplicación.

  1. Inicie la aplicación mediante la ejecución del siguiente comando desde la raíz de la carpeta del proyecto:
electron App/main.js
  1. En la ventana principal de la aplicación debería ver el contenido del archivo index.html y el botón Iniciar sesión.

Prueba del inicio y el cierre de sesión

Una vez cargado el archivo index.html, seleccione Iniciar sesión. Se le pedirá que inicie sesión con la plataforma de identidad de Microsoft:

Solicitud de inicio de sesión

Si da su consentimiento a los permisos solicitados, las aplicaciones web muestran el nombre de usuario, lo que significa que el inicio de sesión se ha realizado correctamente:

Inicio de sesión correcto

Prueba de la llamada de API web

Después de iniciar sesión, seleccione Ver perfil para ver la información de perfil de usuario devuelta en la respuesta de la llamada a Microsoft Graph API. Después del consentimiento, verá la información del perfil que se devuelve en la respuesta:

Información del perfil de Microsoft Graph

Cómo funciona la aplicación

La primera vez que un usuario selecciona el botón Iniciar sesión se llama al método acquireTokenInteractive de Nodo de MSAL. Este método redirige al usuario para iniciar sesión con el punto de conexión de la plataforma de identidad de Microsoft y valida las credenciales del usuario, obtiene un código de autorización y, después, intercambia ese código por un token de identificador, token de acceso y token de actualización. Nodo de MSAL también almacena en caché estos tokens para su uso futuro.

El token de identificador contiene información básica sobre el usuario, como su nombre para mostrar. El token de acceso tiene una duración limitada y expira después de 24 horas. Si piensa usar estos tokens para acceder a un recurso protegido, debe comprobarlo en el servidor back-end para asegurarse de que el token se emitió para un usuario válido en la aplicación.

La aplicación de escritorio que ha creado en este tutorial realiza una llamada de REST a Microsoft Graph API mediante un token de acceso, como token de portador en el encabezado de solicitud (RFC 6750).

Microsoft Graph API requiere el ámbito user.read para leer el perfil del usuario. De forma predeterminada, este ámbito se agrega automáticamente en todas las aplicaciones que se registran en Azure Portal. Otras API de Microsoft Graph, así como las API personalizadas para el servidor back-end, pueden requerir ámbitos extras. Por ejemplo, Microsoft Graph API requiere el ámbito Mail.Read para mostrar el correo electrónico del usuario.

A medida que se agregan ámbitos, puede que se pida a los usuarios que proporcionen otro consentimiento para los ámbitos agregados.

Ayuda y soporte técnico

Si necesita ayuda, desea informar de un problema o desea obtener información sobre las opciones de soporte técnico, consulte Opciones de ayuda y soporte técnico para desarrolladores.

Pasos siguientes

Si quiere profundizar más en el desarrollo de aplicaciones de escritorio de Electron en la plataforma de identidad de Microsoft, consulte nuestra serie de escenarios de varias partes: