Tutorial:在 Electron 桌面应用中让用户登录并调用 Microsoft Graph API

在本教程中,你将构建一个 Electron 桌面应用程序,该应用程序使用 PKCE 的授权代码流来使用户登录并调用 Microsoft Graph。 构建的桌面应用使用适用于 Node.js 的 Microsoft 身份验证库 (MSAL)

请遵循本教程中的步骤执行以下操作:

  • 在 Azure 门户中注册应用程序
  • 创建 Electron 桌面应用项目
  • 向应用添加身份验证逻辑
  • 添加方法以调用 Web API
  • 添加应用注册详细信息
  • 测试应用程序

先决条件

注册应用程序

首先,请完成向 Microsoft 标识平台注册应用程序中的步骤来注册你的应用。

对于应用注册,请使用以下设置:

  • 名称:ElectronDesktopApp(建议)
  • 支持的帐户类型:“仅我的组织目录中的帐户(单租户)”
  • 平台类型:移动和桌面应用程序
  • 重定向 URI:http://localhost

创建项目

创建文件夹来托管你的应用程序,例如 ElectronDesktopApp。

  1. 首先,在终端中更改到你的项目目录,然后运行以下 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. 然后,创建名为 App 的文件夹。 在此文件夹中,创建一个名为 index.html 的文件,该文件将用作 UI。 在其中添加以下代码:

<!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>
  1. 接下来,创建名为 main.js 的文件并添加以下代码:
/*
 * 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);
});

在上述代码片段中,需要初始化 Electron 主窗口对象,并创建一些事件处理程序,以便与 Electron 窗口进行交互。 还需导入配置参数,实例化 authProvider 类以处理登录、注销和令牌获取,以及调用 Microsoft Graph API。

  1. 在同一文件夹 (App) 中,创建另一个名为 renderer.js 的文件,并添加以下代码 :
// 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);
}

呈现器方法由在 preload.js 文件中找到的预加载脚本公开,以便呈现器以安全且受控的方式访问 Node API

  1. 然后,创建新文件 preload.js 并添加以下代码:
// 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));
    },
});

此预加载脚本公开了一个呈现器 API,通过应用为主进程和呈现器进程之间的通信而配置的 IPC 通道,向呈现器进程提供对某些 Node APIs 的受控访问。

  1. 最后,创建一个名为 constants.js 的文件,该文件将存储用于描述应用程序事件的字符串常量:
/*
 * 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,
}

现已为 Electron 应用提供了一个简单的 GUI 和交互。 完成本教程的其余部分之后,项目的文件和文件夹结构应类似于下面这样:

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

向应用添加身份验证逻辑

在 App 文件夹中,创建名为 AuthProvider.js 的文件 。 AuthProvider.js 文件将包含一个身份验证提供程序类,该类将使用 MSAL Node 来处理登录、注销、令牌获取、帐户选择和相关的身份验证任务。 在其中添加以下代码:

/*
 * 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:
             * /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;

在上面的代码片段中,我们首先通过传递配置对象 (msalConfig) 初始化了 MSAL Node PublicClientApplication。 然后,我们公开了 loginlogoutgetToken 方法,以供主模块 (main.js) 调用。 在 logingetToken 中,我们使用 MSAL Node acquireTokenInteractive 公共 API 获取 ID 和访问令牌。

添加 Microsoft Graph SDK

创建一个名为“graph.js”的文件。 “graph.js”文件将包含一个 Microsoft Graph SDK 客户端实例,以便使用通过 MSAL Node 获得的访问令牌访问 Microsoft Graph API 上的数据:

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;

添加应用注册详细信息

创建一个环境文件,以存储在获取令牌时将使用的应用注册详细信息。 为此,请在示例的根文件夹 (ElectronDesktopApp) 中创建名为 authConfig.js 的文件,并添加以下代码:

/*
 * 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,
};

使用从 Azure 应用注册门户获取的值填写以下详细信息:

  • Enter_the_Tenant_Id_here 应是以下各项之一:
    • 如果应用程序支持“此组织目录中的帐户”,请将此值替换为“租户 ID”或“租户名称”。 例如,contoso.microsoft.com
    • 如果应用程序支持“任何组织目录中的帐户”,请将该值替换为“organizations”。
  • Enter_the_Application_Id_Here:已注册应用程序的应用程序(客户端)ID。
  • Enter_the_Cloud_Instance_Id_Here:在其中注册应用程序的 Azure 云实例。
    • 对于国家/地区云(例如中国云),可以在国家/地区云中找到相应值。
  • Enter_the_Graph_Endpoint_Here 是应用程序应与之通信的 Microsoft Graph API 实例。
    • 对于国家/地区云部署中的终结点,请参阅 Microsoft Graph 文档中的国家/地区云部署

测试应用

已完成应用程序创建,现在可以启动 Electron 桌面应用并测试应用的功能。

  1. 从项目文件夹的根目录中运行以下命令,启动应用:
electron App/main.js
  1. 在应用程序主窗口中,应会显示 index.html 文件的内容和“登录”按钮。

测试登录和注销

加载 index.html 文件后,选择“登录”。 系统将提示你使用 Microsoft 标识平台进行登录:

sign-in prompt

如果你同意请求的权限,Web 应用程序会显示用户名,表示登录成功:

successful sign-in

测试 Web API 调用

登录之后,选择“查看个人资料”,以查看在调用 Microsoft Graph API 的响应中返回的用户个人资料信息。 同意后,可查看响应中返回的用户个人资料信息:

profile information from Microsoft Graph

应用程序的工作原理

用户首次选择“登录”按钮时,使用 MSAL Node 的 acquireTokenInteractive 方法。 此方法将用户重定向到使用 Microsoft 标识平台终结点登录,并验证用户的凭据,获取“授权代码”,然后用该代码交换 ID 令牌、访问令牌和刷新令牌。 MSAL Node 还会缓存这些令牌以供将来使用。

ID 令牌包含有关用户的基本信息,例如其显示名称。 访问令牌的生存期有限,将在 24 小时后过期。 如果计划使用这些令牌来访问受保护的资源,则后端服务器必须验证该令牌,以保证该令牌颁发给了应用程序的有效用户。

在本教程中创建的桌面应用将使用访问令牌作为请求标头 (RFC 6750) 中的持有者令牌来对 Microsoft Graph API 进行 REST 调用。

Microsoft Graph API 需要 user.read 作用域来读取用户的个人资料。 默认情况下,此作用域会自动添加到在 Azure 门户中注册的每个应用程序。 Microsoft Graph 的其他 API 以及后端服务器的自定义 API 可能需要额外的作用域。 例如,Microsoft Graph API 需要使用 Mail.Read 作用域来列出用户的电子邮件。

当你添加作用域时,系统可能会提示你的用户为添加的作用域提供其他同意。

帮助和支持

如果需要帮助、需要报告问题,或者需要详细了解支持选项,请参阅面向开发人员的帮助和支持

后续步骤

如果想深入了解 Microsoft 标识平台上的 Node.js 和 Electron 桌面应用程序开发,请参阅由多部分组成的方案系列: