共用方式為


教學課程:在 Electron 桌面應用程式中登入使用者並呼叫 Microsoft Graph API

在本教學課程中,您會建置 Electron 桌面應用程式,以使用 PKCE 的授權碼流程登入使用者並呼叫 Microsoft Graph。 您所建置的桌面應用程式會使用適用於 Node.js 的 Microsoft 驗證程式庫 (MSAL)

適用於白色核取記號的綠色圓圈。 員工租戶 灰色 X 符號的白色圓圈。 外部租戶 (深入了解)

在本教學課程中,您會:

  • 在 Azure 入口網站中註冊應用程式
  • 建立 Electron 桌面應用程式專案
  • 將驗證邏輯新增至應用程式中
  • 新增方法以呼叫 Web API
  • 新增應用程式註冊詳細資料
  • 測試應用程式

必要條件

建立專案

備註

本教程中提供的 Electron 範例程式特別為搭配 MSAL-node 使用而設計。 電子應用程式中不支援 MSAL 瀏覽器。 請確定您已完成下列步驟,以正確設定您的專案。

建立資料夾來裝載您的應用程式,例如 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>
    
  3. 接下來,建立名為 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,
    }
    

您現在已經擁有簡單的 GUI 和互動功能,適用於您的 Electron 應用程式。 完成本教學課程的其餘部分之後,您專案的檔案和資料夾結構看起來應該會如下所示:

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 節點來處理登入、登出、取得權杖、帳戶選取和相關的驗證工作。 將下列程式碼新增至:

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

在上述程式碼片段中,我們首先透過傳入設定物件來初始化 MSAL Node PublicClientApplicationmsalConfig。 接著,我們會公開主要模組 (login) 所要呼叫的方法 logoutgetToken 以及 。 在 logingetToken 中,使用 MSAL 節點 acquireTokenInteractive 公用 API 來取得識別碼和存取權杖。

新增 Microsoft Graph SDK

建立名為 graph.js 的檔案。 Graph.js 檔案將包含 Microsoft Graph SDK 用戶端的執行個體,使用 MSAL 節點所取得的存取權杖,來協助存取 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 應該是下列其中一項:
    • 如果您的應用程式支援 [此組織目錄中的帳戶],請將此值取代為 [租用戶識別碼] 或 [租用戶名稱]。 例如: contoso.microsoft.com
    • 如果您的應用程式支援 [任何組織目錄中的帳戶],請將此值取代為 organizations
    • 如果您的應用程式支援 [任何組織目錄中的帳戶及個人的 Microsoft 帳戶],請將此值取代為 common
    • 若要將支援範圍限制為 [僅限個人 Microsoft 帳戶],請將此值取代為 consumers
  • Enter_the_Application_Id_Here:是您所註冊應用程式的應用程式 (用戶端) 識別碼
  • Enter_the_Cloud_Instance_Id_Here:您的應用程式已註冊的 Azure 雲端執行個體。
    • 針對主要 (或全域) Azure 雲端,請輸入 https://login.microsoftonline.com/
    • 針對國家雲端 (例如中國),您可以在國家雲端中找到適當的值。
  • Enter_the_Graph_Endpoint_Here 是應用程式所應通訊的 Microsoft Graph API 執行個體。
    • 針對全域的 Microsoft Graph API 端點,請將此字串的兩個出現次數都替換成 https://graph.microsoft.com/
    • 針對國家雲端部署中的端點,請參閱 Microsoft Graph 文件中的國家雲端部署

測試應用程式

您已完成應用程式的建立,現在已做好準備而可以啟動 Electron 桌面應用程式,並測試應用程式的功能了。

  1. 請從專案資料夾的根目錄內執行下列命令,以啟動應用程式:
electron App/main.js
  1. 在應用程式主視窗中,您應該會看到 index.html 檔案和 [登入] 按鈕。

測試登入和登出

載入 index.html 檔案之後,請選取 [登入]。 系統會提示您使用 Microsoft 身分識別平台登入:

登入提示

如果您同意要求的權限,Web 應用程式便會顯示您的使用者名稱,以表示您已成功登入:

成功登入

測試 Web API 呼叫

登入之後,選取 [查看設定檔],以檢視呼叫 Microsoft Graph API 時的回應中傳回的使用者設定檔資訊。 同意之後,您將查看回應中傳回的個人資料資訊:

來自 Microsoft Graph 的設定檔資訊

應用程式的運作方式

當使用者首次選取 [登入] 按鈕時,MSAL 節點的 acquireTokenInteractive 方法。 此方法會將使用者重定向到 Microsoft 身分識別平台端點以登入,並驗證使用者的認證,取得授權碼,然後將該代碼兌換為識別權杖、存取權杖和刷新權杖。 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 身分識別平台上開發 Electron 桌面應用程式,請參閱我們多部分的案例系列: