다음을 통해 공유


자습서: Electron 데스크톱 앱에서 사용자 로그인 및 Microsoft Graph API 호출

이 자습서에서는 PKCE와 함께 권한 부여 코드 흐름을 사용하여 사용자를 로그인하고 Microsoft Graph를 호출하는 Electron 데스크톱 애플리케이션을 빌드합니다. 빌드한 데스크톱 앱은 Node.js용 MSAL(Microsoft 인증 라이브러리)을 사용합니다.

다음을 수행하려면 이 자습서의 단계를 따릅니다.

  • Azure Portal에 애플리케이션 등록
  • Electron 데스크톱 앱 프로젝트 만들기
  • 앱에 인증 논리 추가
  • 메서드를 추가하여 웹 API 호출
  • 앱 등록 세부 정보 추가
  • 앱 테스트

필수 조건

응용 프로그램 등록

먼저 Microsoft ID 플랫폼에 애플리케이션 등록의 단계를 완료하여 앱을 등록합니다.

앱 등록 시 다음 설정을 사용합니다.

  • 이름: 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이라는 폴더를 만듭니다. 이 폴더 내에 UI 역할을 할 index.html이라는 파일을 만듭니다. 거기에 다음 코드를 추가합니다.

    <!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);
    }
    

렌더러 메서드는 안전하고 제어된 방식으로 Node API에 대한 렌더러 액세스 권한을 부여하기 위해 preload.js 파일에 있는 사전 로드 스크립트에 의해 노출됩니다.

  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));
        },
    });
    

이 사전 로드 스크립트는 기본 프로세스와 렌더러 프로세스 간의 통신을 위해 구성된 IPC 채널을 적용하여 렌더러 프로세스에서 제어된 액세스 권한을 일부 Node APIs에게 부여하는 렌더러 API를 노출합니다.

  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 노드를 사용하여 로그인, 로그아웃, 토큰 획득, 계정 선택 및 관련 인증 작업을 처리하는 인증 공급자 클래스가 포함됩니다. 거기에 다음 코드를 추가합니다.

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

위의 코드 조각에서 먼저 구성 개체(msalConfig)를 전달하여 MSAL 노드 PublicClientApplication을 초기화했습니다. 그런 다음, 기본 모듈(main.js)이 호출할 login, logoutgetToken 메서드를 노출했습니다. logingetToken에서는 MSAL 노드 acquireTokenInteractive 공용 API를 사용하여 ID와 액세스 토큰을 획득합니다.

Microsoft Graph SDK 추가

graph.js라는 파일을 만듭니다. graph.js 파일에는 MSAL 노드에서 가져온 액세스 토큰을 사용하여 Microsoft Graph API 데이터에 쉽게 액세스할 수 있도록 하는 Microsoft Graph SDK 클라이언트 인스턴스가 포함됩니다.

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로 바꾸세요.
    • 애플리케이션에서 모든 조직 디렉터리의 계정 및 개인 Microsoft 계정을 지원하는 경우 이 값을 common으로 바꿉니다.
    • 개인 Microsoft 계정만 지원하도록 제한하려면 이 값을 consumers로 바꿉니다.
  • Enter_the_Application_Id_Here: 등록한 애플리케이션의 애플리케이션(클라이언트) ID입니다.
  • 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 ID 플랫폼으로 로그인하라는 메시지가 표시됩니다.

로그인 프롬프트

요청한 권한에 동의하면 웹 애플리케이션에서 사용자 이름을 표시하여 성공적으로 로그인했음을 나타냅니다.

로그인 성공

웹 API 호출 테스트

로그인한 후에 프로필 보기를 선택하여 Microsoft Graph API에 대한 호출에서 응답으로 반환된 사용자 프로필 정보를 확인합니다. 동의하면 응답에 반환된 프로필 정보를 볼 수 있습니다.

Microsoft Graph의 프로필 정보

애플리케이션 작동 방식

사용자가 로그인 단추를 처음 선택하면 MSAL 노드의 acquireTokenInteractive 메서드가 호출됩니다. 이 메서드는 사용자를 Microsoft ID 플랫폼 엔드포인트로 로그인하도록 리디렉션하고 사용자 자격 증명의 유효성을 검사한 후, 권한 부여 코드를 가져온 다음, 해당 코드를 ID 토큰, 액세스 토큰 및 새로 고침 토큰으로 교환합니다. 또한 MSAL 노드는 나중에 사용할 수 있도록 이러한 토큰을 캐시합니다.

ID 토큰에는 사용자에 대한 기본 정보(예: 표시 이름)가 포함되어 있습니다. 액세스 토큰은 수명이 제한되어 있으며 24시간 후에 만료됩니다. 이러한 토큰을 보호된 리소스에 액세스하는 데 사용하려는 경우 백엔드 서버는 토큰이 애플리케이션의 유효한 사용자에게 발급되었음을 보장하기 위해 해당 토큰의 유효성을 검사해야 합니다.

이 자습서에서 만든 데스크톱 앱은 요청 헤더(RFC 6750)의 전달자 토큰으로 액세스 토큰을 사용하여 Microsoft Graph API에 대한 REST 호출을 수행합니다.

Microsoft Graph API는 user.read 범위가 있어야만 사용자 프로필을 읽을 수 있습니다. 기본적으로 이 범위는 Azure Portal에 등록된 모든 애플리케이션에 자동으로 추가됩니다. 다른 Microsoft Graph용 API와 백 엔드 서버용 사용자 지정 API에는 추가 범위가 필요할 수 있습니다. 예를 들어 Microsoft Graph API에는 사용자의 이메일을 나열하기 위해 Mail.Read 범위가 필요합니다.

범위를 추가하면 추가된 범위에 대한 또 다른 동의를 제공하라는 메시지가 표시될 수 있습니다.

도움말 및 지원 

도움이 필요하거나, 문제를 보고하거나, 지원 옵션에 대해 알아보려면 개발자를 위한 도움말 및 지원을 참조하세요.

다음 단계

Microsoft ID 플랫폼에서 Node.js 및 Electron 데스크톱 애플리케이션 개발에 대해 자세히 알아보려면 여러 부분으로 구성된 시나리오 시리즈를 참조하세요.