构建使用PKCE授权流的Electron桌面应用
本教程将指导你构建一个Electron桌面应用程序,该程序使用PKCE(Proof Key for Code Exchange)的授权代码流来实现用户登录并调用Microsoft Graph API。应用基于适用于Node.js的Microsoft身份验证库(MSAL Node),确保安全且高效的身份验证处理。
通过本教程,你将完成以下任务:
- 在Azure门户中注册应用程序
- 创建Electron桌面应用项目
- 向应用添加身份验证逻辑
- 添加方法以调用Web API
- 配置应用注册详细信息
- 测试应用程序功能
先决条件
在开始之前,请确保满足以下条件:
- 拥有一个Azure租户。可以使用默认目录或创建新租户。
- 在Microsoft Entra管理中心注册一个新应用,并配置为“仅适用于此组织目录中的帐户”。详细步骤请参考注册应用程序。在应用概述页中记录以下值:
- 应用程序(客户端)ID
- 目录(租户)ID
- 为应用添加重定向URI。使用“移动和桌面应用程序”平台配置,添加重定向URI:
http://localhost。具体方法参见如何添加重定向URI。 - 安装Node.js
- 安装Electron
- 准备Visual Studio Code或其他代码编辑器
创建项目
本教程提供的Electron示例专为使用MSAL Node设计。请注意,Electron应用中不支持MSAL浏览器,因此请严格按照以下步骤设置项目。
首先,创建一个文件夹来托管应用程序,例如ElectronDesktopApp。
- 在终端中切换到项目目录,运行以下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
- 创建一个名为
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>
<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>
<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>
<script src="./renderer.js"></script>
</body>
</html>
- 创建
main.js文件,添加以下代码以初始化Electron主窗口并处理事件:
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', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
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);
});
- 创建
renderer.js文件,用于处理UI事件和更新界面:
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);
});
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文件,暴露安全的API给渲染进程:
const { contextBridge, ipcRenderer } = require('electron');
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)),
});
- 创建
constants.js文件,定义IPC消息常量:
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 };
完成以上步骤后,项目结构应如下所示:
ElectronDesktopApp/
├── App
│ ├── AuthProvider.js
│ ├── constants.js
│ ├── graph.js
│ ├── index.html
│ ├── main.js
│ ├── preload.js
│ ├── renderer.js
│ └── authConfig.js
├── package.json
向应用添加身份验证逻辑
在App文件夹中创建AuthProvider.js文件,实现身份验证提供程序类,使用MSAL Node处理登录、注销和令牌获取。
const { PublicClientApplication, InteractionRequiredAuthError } = require('@azure/msal-node');
const { shell } = require('electron');
class AuthProvider {
constructor(msalConfig) {
this.msalConfig = msalConfig;
this.clientApplication = new PublicClientApplication(this.msalConfig);
this.cache = this.clientApplication.getTokenCache();
this.account = null;
}
async login() {
const authResponse = await this.getToken({ scopes: [] });
return this.handleResponse(authResponse);
}
async logout() {
if (!this.account) return;
try {
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;
}
}
async handleResponse(response) {
if (response !== null) {
this.account = response.account;
} else {
this.account = await this.getAccount();
}
return this.account;
}
async getAccount() {
const currentAccounts = await this.cache.getAllAccounts();
if (!currentAccounts) {
console.log('No accounts detected');
return null;
}
if (currentAccounts.length > 1) {
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;
添加Microsoft Graph SDK
创建graph.js文件,初始化Microsoft Graph SDK客户端,以便使用访问令牌调用Graph API。
const { Client } = require('@microsoft/microsoft-graph-client');
require('isomorphic-fetch');
const getGraphClient = (accessToken) => {
const graphClient = Client.init({
authProvider: (done) => {
done(null, accessToken);
},
});
return graphClient;
};
module.exports = getGraphClient;
添加应用注册详细信息
在根文件夹中创建authConfig.js文件,配置MSAL Node所需的参数。
const { LogLevel } = require("@azure/msal-node");
const AAD_ENDPOINT_HOST = "Enter_the_Cloud_Instance_Id_Here";
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,
},
},
};
const GRAPH_ENDPOINT_HOST = "Enter_the_Graph_Endpoint_Here";
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。Enter_the_Application_Id_Here:应用程序的客户端ID。Enter_the_Cloud_Instance_Id_Here:Azure云实例ID,对于国家云请参考相关文档。Enter_the_Graph_Endpoint_Here:Microsoft Graph API端点,对于国家云请参考文档。
测试应用程序
完成以上步骤后,可以启动应用程序进行测试。
- 从项目根目录运行以下命令启动Electron应用:
electron App/main.js
- 应用主窗口将显示
index.html内容,包括“登录”按钮。
测试登录和注销
点击“登录”按钮,系统将提示你使用Microsoft标识平台进行登录。成功登录后,界面会显示用户名,表示登录成功。
测试Web API调用
登录后,点击“查看个人资料”按钮,应用将调用Microsoft Graph API获取用户个人资料信息,并显示在界面上。
应用程序的工作原理
当用户首次点击“登录”时,应用使用MSAL Node的acquireTokenInteractive方法引导用户登录Microsoft标识平台,获取授权码并交换为ID令牌、访问令牌和刷新令牌。这些令牌被缓存以供后续使用。
ID令牌包含用户基本信息,访问令牌用于作为持有者令牌在请求头中调用受保护的API,如Microsoft Graph。本应用使用User.Read作用域来读取用户个人资料,其他API可能需要额外作用域。
帮助和支持
如果在开发过程中需要帮助或遇到问题,请参考面向开发人员的帮助和支持。
后续步骤
想深入了解Microsoft标识平台上的Node.js和Electron桌面应用开发,请参阅相关多部分场景系列文档。