electronでAzure AD認証を行い、Graph APIを叩く - 準備編
社内で利用しようとしてるとあるMicrosoftの製品。しかし、現場に浸透せずこのままでは朽ちていくだけ。殆どの企業で言える事なのだけれど、入れる事がゴールになっていて、現場の利用者のフォローや研修、また利用がしやすい(またはそうなるような動機づけとなるもの)を一切やらないとこうなるという典型例です。
しかし、使え!!と言ったり、強制!!といった強権を発動しても普及はしません。
そこで、現場のレベルに合わせて、まずは一部の機能だけで運用出来るように、クライアントアプリ@electronを作る事になりました。ウェブサービスの悪い点はフル機能をいきなり全面に全部出してしまう事ですね。あれなんとかならないのだろうか(天丼が食べたいのに、天ぷら懐石フルコースとか出されても困る)。
目次
今回使用するプロジェクトとモジュール
- Microsoft Azure Active Directory Passport.js Plug-In
- passport - npm
- express - npm
- express-generator - npm
- npm-run-all - npm
- express-session - npm
- ejs - npm
この他に、認証に必要なMicrosoft Azure-ADおよびMicrosoft Graph APIも使います。GASの時にも利用してるので合わせて参照してみてください。
事前準備
Azureでプロジェクト作成
ちょっと前までは、Application Registration Portalからアプリの登録ができたのですが、現在はMicrosoft Azureの一部になっています。Microsoft365アカウントでなくとも、無料アカウントで始められるようになっているので、Microsoftアカウントが無い人はまずアカウントを作って、サインインしましょう。
さて、アプリの登録とそれに伴うクライアントIDなどの取得手順は以下の通り。
- アプリの登録にて登録を開始する
- アプリケーションの登録をクリックする
- 名前を入力、リダイレクトURIは「webを選択しhttp://localhost:3000/auth/callback」を入力(127.0.0.1はNG)
- 登録ボタンをクリックする
- 出てきた中で、「アプリケーション(クラと書かれているのがクライアントID」なので、このコードをメモしておく
- 左サイドバーより、「証明書とシークレット」をクリック
- 「新しいクライアントシークレット」をクリックする
- 今回は特に有効期限を設けないで追加をクリック
- これで値に「クライアントシークレット」が生成されて手に入りました。このシークレットはこの時だけしか表示されないので、注意してください。
- つづけて、左サイドバーより「APIのアクセス許可」をクリックする
- Microsoft APIの中にある「Microsoft Graph」をクリックする。
- 「委任されたアクセス許可」をクリックする
- デフォルトでUser.ReadがすでにONなので、offline_access、Files.ReadWriteを検索してONにしましょう。
- アクセス許可の追加をクリックする
- 追加出来たら、xxxxxに管理者の同意を与えますをクリックします。すると、状態が緑色になります(同意を与えずとも管理者権限を要さない場合ならば使うことが可能です。この場合毎回要求されてるアクセス許可が出るようになります)
- 次に左サイドバーより「認証」をクリック
- 暗黙の付与にて、「アクセストークン」にチェックを入れる
- サポートされているアカウントの種類に於いては、「マルチテナント」にしておきました(企業内で使う場合にはその組織のテナントで良いでしょう)
- 保存をクリック
- 概要のエンドポイントをクリックすると、いろいろなエンドポイントURLが出る。
- 概要のディレクトリ(テナントの数値はメモっておきます。あとでプログラム中で使用します。
※3.でWebを選ばないSPAを選んでしまうと、Proof Key for Code Exchange by OAuth Public Clientsといったエラーが出てしまい認証ができませんので注意。
※Teamsのメッセージ取得などの場合は、13.ではoffline_accessに加えて、Group.Read.All, Group.ReadWrite.Allを加えます。要求するscopeが異なるので作るアプリで何が必要なのか注意が必要です。Graph Explorerで調べて起きましょう。
図:アプリの登録から全ては始まります。
図:Graphを選択する
図:認証の設定変更に注意
electronプロジェクトの用意
Web APIを構築する時にも利用するexpressを使うのがポイントです。しかし、electronからexpressを扱う場合にはちょっと工夫が必要です。expressが立ち上げるWebサーバにelectronからアクセスする形を取る必要があるので、以下の手順で作業を行います。
まずは、express-generatorをグローバルインストールする
1 |
npm i express-generator -g |
いつもならば、npm init -yでプロジェクトを作る所ですが、今回は以下のコマンドでexpress-generatorを使ってプロジェクトを作成します。今回はazureというディレクトリを作って作業をします。
1 2 3 |
express azure cd azure npm install |
azureというディレクトリが作られて、中にapp.js他たくさんの何かが生成されます。この段階で、以下のコマンドでテストをしてみます。
1 |
npm start |
Windowsの場合、Firewallが作動して許可するかどうかを聞いてくるので、プライベートネットワークにチェックを入れてアクセスを許可するにチェックを入れる。そして、Chromeなどでhttp://localhost:3000/にアクセスした場合に、welcome to expressが表示されれば成功です。Ctrl+Cでnpm startしたサーバを停止できます。
※企業などでクライアントPCでこのアクセス許可が許されていない場合には、別途expressだけで認証用サーバを立てて、electronからそのサーバへアクセスする方式にしておけば、クリアできます。
図:expressによるWebサーバが立ち上がる際の画面
続けて、app.jsの入ってるフォルダに空のindex.jsを作成し、以下のコマンドで他のモジュールをインストールしておきます。
1 2 3 4 5 |
npm i passport --save npm i passport-azure-ad npm i npm-run-all --save-dev npm i express-session --save-dev npm i ejs |
生成されているpackage.jsonを開いて、"main": "index.js",を追記して保存します。以下のような感じになるはずです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
{ "name": "azure", "version": "0.0.0", "description": "azure認証するだけのアプリ", "main": "index.js", "private": true, "scripts": { "start": "node ./bin/www" }, "dependencies": { "cookie-parser": "~1.4.4", "debug": "~2.6.9", "express": "~4.16.1", "http-errors": "~1.6.3", "jade": "~1.11.0", "morgan": "~1.9.1", "passport": "^0.4.1", "passport-azure-ad": "^4.2.1" }, "devDependencies": { "npm-run-all": "^4.1.5" } } |
index.jsにとりあえず以下のテスト用コードを追記してみる。npm startでexpressのサーバを起動した状態で、electron .で起動してみて、welcome to expressが表示されればテストはOK。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
'use strict'; //標準モジュールの呼び出し const electron = require('electron'); const { app, Tray, Menu, dialog } = require('electron'); const BrowserWindow = electron.BrowserWindow; var fs = require('fs'); //Node.js側とHTML側で通信をするモジュール const ipcMain = require('electron').ipcMain; // メインウィンドウはグローバル宣言 let mainWindow = null; //Expressにアクセスする const express = require('./app'); express.listen(3000, 'localhost'); // Electronの初期化完了後に実行 app.on('ready', () => { // メイン画面の表示。ウィンドウの幅、高さを指定できる mainWindow = new BrowserWindow({ 'width': 800, 'height': 600, 'autoHideMenuBar':true, //nodeIntegrationを有効にしないとrenderProcessでrequireを使えない。v5.0.0ではデフォルトで廃止 webPreferences: { nodeIntegration: true }, 'resizable':true, 'fullscreenable':true, 'fullscreen':false }); //初期ページの表示 mainWindow.loadURL('http://localhost:3000') // ウィンドウが閉じられたらアプリも終了 mainWindow.on('closed', () => { mainWindow = null }) }) //全てのウィンドウが閉じたら終了 app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } }) |
しかし、このままでは別途npm startでexpressを立ち上げて置いてから、electron .で起動する必要があるため、package.jsonのscriptsのラインにnpm-run-allを使って複数のタスクを同時に起動するように細工をします。これで、テスト時は、electron .だけで全て起動します。
1 2 3 4 5 |
"scripts": { "start": "node ./bin/www", "electron": "electron .", "dev": "npm-run-all --parallel electron start" } |
図:expressサーバをelectronから開けた
ログイン画面とトークンの取得
ここからが一番の峠道です。通常であればindex.jsに色々と記述する所ですが、今回の認証アプリの場合には、app.jsの方に記述する事になります。自動生成されたapp.jsにはすでに色々記述されているので、これを改造して使います。
今回は、こちらのソースコードを利用しています(少し改変を加えています)
ソースコード
app.js側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 |
//モジュールの読み込み var createError = require('http-errors'); var express = require('express'); var path = require('path'); const join = require('path').join; var cookieParser = require('cookie-parser'); var logger = require('morgan'); var bodyParser = require('body-parser'); var session = require('express-session'); var fs = require("fs"); //passportの設定 var passport = require('passport'); var strategy = require('passport-azure-ad').OIDCStrategy; passport.serializeUser(function(user, done) { done(null, user); }); passport.deserializeUser(function(user, done) { done(null, user); }); //認証用データ var auth = "https://login.microsoftonline.com/ここにテナントのIDを入れる/v2.0/.well-known/openid-configuration"; var redirecturi = "http://localhost:3000/auth/callback"; var scope = [ 'profile', 'offline_access', 'user.read', 'Files.ReadWrite' ] var clientID = "ここにクライアントIDを入れる"; var clientsecret = "ここにクライアントシークレットを入れる"; //Azure AD認証用のオプション var options = { identityMetadata: auth, clientID: clientID, responseType: "code", responseMode: "form_post", redirectUrl: redirecturi, allowHttpForRedirectUrl: true, clientSecret: clientsecret, validateIssuer: false, scope: scope, passReqToCallback: false }; //認証フロー passport.use(new strategy(options, (iss, sub, profile, access_token, refresh_token, done) => { try{ if (profile.oid) { //データを組み立て const user = { iss, sub, profile, access_token, refresh_token }; //Access Tokenをテキストに書き出す fs.writeFileSync("user.txt", user.access_token); //ユーザデータを返す return done(null, user); } //取得データを返す return done(null, false); }catch(err){ //エラートラップ return done(null, err); } } )); //expressの設定 var app = express(); //セッション app.use(session({ secret: 'graphman super', resave: false, saveUninitialized: false, cookie: { httpOnly: false } })); //ビューテンプレート app.set('views', join(__dirname,'/views')); //index.htmlを読み込ませる app.set('view engine', 'ejs'); app.engine('html', require('ejs').renderFile); //passportの初期化 app.use(passport.initialize()); app.use(passport.session()); //その他の設定 app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: false })); app.use(cookieParser()); app.use(logger('dev')); //ルーティング //★初期ページ app.get('/', (req, res) => { //ejsの力でindex.html側へデータを反映する res.render('index.html', { user: req.user }); }); //サインインクリック時 app.get('/auth/signin', passport.authenticate('azuread-openidconnect', { failureRedirect: '/' }) , (req, res) => { res.redirect('/'); } ); //サインイン後のコールバックを受け取った時 app.post('/auth/callback', passport.authenticate('azuread-openidconnect', { failureRedirect: '/' }) , (req, res) => { res.redirect('/'); } ); //サインアウトをクリックした時 app.get('/auth/signout', (req, res) => { req.session.destroy((err) => { if (err) { res.send(err) return } res.redirect('/') }) } ); //404検知 app.use(function(req, res, next) { next(createError(404)); }); //エラーハンドラー(リリース直前までコメントアウト推奨) //app.use(function(err, req, res, next) { // res.locals.message = err.message; // res.locals.error = req.app.get('env') === 'development' ? err : {}; //エラーページを表示 //res.status(err.status || 500); //res.render('error'); //}); //サーバ開始 module.exports = app; |
- テナントのIDについてですが、commonにしてあると、認証ができません。
- テナントのIDですが、冒頭で取得したディレクトリ(テナントの数値に置き換えてください。これがテナントIDになります。
- サインイン時、コールバック時、サインアウト時の3つのアクションに対してそれぞれ処理を記述しています。
- エラーハンドリングをコメントアウトしていますが、コメントアウト解除をするとデバッグがしにくくなりますので、リリース前に解除するようにしてください。
- 認証が成功すると、user.txtという名前のファイルにAccess Tokenが書き出され、index.html側にそれらの情報が反映されます。
- scopeはPortalで設定したものと同じものに加えて、profileは入れておいてください。認証結果が表示されなくなってしまうので。
図:テナントIDが重要です
index.html側コード
Githubにアップされているindex.htmlをそのまま利用させていただきました。ダウンロードしたindex.htmlは、viewsディレクトリに入れておけばオッケーです。
ejsを利用していて、express側からuserという配列を受け取ると、index.html内の所定の箇所にデータが展開される仕組みになっています。認証が完了し、Access Tokenなどを取得するとその情報が一目瞭然となります。個人的にはVue.js使ったほうがわかりやすい気がするけれど。
図:返ってきた値がindex.htmlへ反映される
実行結果と注意点
実行結果
electronを実行すると、signinとsingoutの2つが出てきます。サインインをクリックするとelectron内でMicrosoftのログイン画面が出てきて、メールアドレスとパスワードを入力します。
サインイン時にScopeで設定したものについて承認を求められるので、「承諾」とする事で、認証が実行されて、Access Tokenなどが取得出来るようになります。
図:Microsoftのログイン画面が出てきた
図:承諾をする事でトークンが返却される
注意点
ログインクッキー
ログイン画面に於いて、パスワードを保存等すると、electronにcookieとして保存されます。よって、ここで保存されてしまうと次回以降ログイン時に利用されるので、ログイン画面が出てこなくなりますが、そのcookieは、expressが保存してるものではなく、electronが保持しています。
このcookieはキャッシュクリアではクリアされません。Windowsの場合保存されているパスは以下の場所で、CookiesというSQLiteファイルに格納されています。
1 |
C:\Users\ユーザ名\AppData\Roaming\アプリの名称 |
アプリの名称は、package.jsonに記述されているname属性の名前が利用されています。このディレクトリにあるCookiesを削除すれば、再度ログインが出来るようになります。しかし、これを手動でやっていては非常に面倒なので、singoutを実行したら、クリアするコードをapp.getの/auth/signoutに追加実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
//sessionを定義 const { session } = require('electron'); //express-sessionのほうは変数名を変更しておく var ses = require('express-session'); ・・・中略・・・ //サインアウトをクリックした時 app.get('/auth/signout', (req, res) => { req.session.destroy((err) => { if (err) { res.send(err) return } //user.jsonファイルを廃棄する try { //認証ファイルを削除する fs.unlinkSync('user.json'); //ログインCookieを削除する deleteAllCookies(); //ダイアログオプション var options ={ type:'info', title:"サインアウト", button:['OK'], message:'トークンは廃棄されました', detail: "Access Tokenは廃棄されました。" } dialog.showMessageBox(null,options); } catch (error) { //何もしない } //トップページに移動する res.redirect('/') }) } ); //Cookiesの中身を空にする function deleteAllCookies() { console.log("Cookies Clear"); session.defaultSession.cookies.get({}, (error, cookies) => { cookies.forEach((cookie) => { let url = ''; // get prefix, like https://www. url += cookie.secure ? 'https://' : 'http://'; url += cookie.domain.charAt(0) === '.' ? 'www' : ''; // append domain and path url += cookie.domain; url += cookie.path; session.defaultSession.cookies.remove(url, cookie.name, (error) => { if (error) console.log(`error removing cookie ${cookie.name}`, error); }); }); }); } |
- 冒頭でsessionにて変数を定義してrequire('electron')を追加する
- express-sessionの変数がかぶってるので、こちらは、var ses = require('express-session');に変更し、app.useはapp.use(ses({に変更する
- deleteAllCookies()関数を作成し、全データをクリアするようにする。CookiesというSQLiteファイルはElectronが掴んでしまってるので、SQLiteモジュールからDELETEは出来ません。
- app.get('/auth/signout'にて、deleteAllCookies()を実行するように仕込む
これで、再びsigninをクリックするとログイン画面が出てくるようになります。
※最新版のElectron v12での挙動
上記のコードをElectron v12で動かしてみたところ、クッキーがデリートされない。。ので、deleteAllCookies関数を以下のように単純化したところ、問題なく動きました。
1 2 3 4 |
//Cookiesの中身を空にする function deleteAllCookies() { session.defaultSession.clearStorageData([], (data) => {}) } |
アクセストークンについて
今回はuser.txtにAccess Tokenを書き出すようにしていますが、実際にアプリケーションとして利用する場合には、各OSの資格情報マネージャに格納すべきです。
今回はここでは紹介していませんが、以前紹介した記事において、node.jsから資格情報マネージャに情報を格納する「keytar」モジュールを使って、Access TokenやRefresh Tokenを格納しておくと良いでしょう。
また、アクセストークンは基本、1時間で失効します。そのままでは再度ログインして新しいAccess Tokenの取得が必要ですが、これでは不便です。Refresh Tokenを利用して新しいAccess Tokenを自動で取得する仕組みが別途必要になります。この辺りは次回の記事にて解説してみようと思います。
Account is Requiredエラー
業務用のテナントで開発してた際に遭遇したエラーですが、プロジェクト作成者である自分は何ら問題なく認証してAccess Tokenを取得できるのですが、同じテナント組織内にいる別のユーザが認証をしようとしたら、Account is Requiredエラーが出て認証が出来ませんでした。
組織外の人間に認証を許可する場合には、Azure Active Directoryに対象のユーザを登録すれば組織内として扱ってくれるので問題ないのですが、もともと同じ組織内なのに認証出来ない問題を調査してみたところ
「Microsoft Graph APIのscopeにopenidが入っていなかった」
のが原因でした。同様の問題に遭遇した場合、アクセス許可としてopenidを追加しましょう。ただし、Node.jsのコード内ではscopeにopenidをあえて追加する必要性はないみたい。なくても認証は出来ました。
図:結構嵌った問題でした
ビルドしたらuser.jsonが見つからない
ややこしいのですが、開発版のコードの場合、fsなりでファイルを読みにいく場合、ファイルが実行するディレクトリと同じフォルダにある場合には__dirnameをつけずにファイルを指定すると読めない場合があります。逆にビルドした場合には、__dirnameがついてると読めないケースがあります(exeがある場所にuser.jsonがあるのに、__dirnameがついてるとapp内を読みに行ってしまう。
Electron開発ではこのように開発途中とビルド版とで挙動に差が生じる事があります。そこで、開発版なのか?ビルド版なのかを判定しパスやコードの挙動を変える必要がありますが、都度変えていると大変なので、判定結果に基づいて変数に格納するようにすると良いでしょう。
こちらでコメントでいただきました。ありがとうございます。
実際にこんな感じで変数を用意したり、appに対して挙動を設定します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
//自動起動設定を初期化 var rocketman = new AutoLaunch({ name:'ロケットマン', path:app.getPath('exe'), }); if(app.isPackaged){ //リリース版はこちらでないとNG app.setAsDefaultProtocolClient('kousu', process.execPath); userjson = "user.json"; //自動起動登録を有効化 rocketman.isEnabled() .then(function(isEnabled){ if(isEnabled){ return; } rocketman.enable(); }) .catch(function(err){ //エラー捕捉時の動作 }); }else{ //開発中はこちらでないとNG app.setAsDefaultProtocolClient('kokusu', process.execPath, [path.resolve(process.argv[1])]); userjson = __dirname + "/user.json"; } |
- app.isPackagedにてビルド版なのかどうか判定が可能。trueが返ってきたら、exeからの実行となるので、user.jsonは直下をそのまま指定する
- 対して開発版の場合、user.jsonは__dirnameをつけてフルパスを指定する。また、urlschemeの設定もこの方法で分岐ができる
- 両者でuser.jsonの読み方が異なる理由は、ビルド版の場合exeの直下は読めますが、開発版の場合electron .で実行する為、そのフォルダの直下ではなく、electron.exeの直下を読みに行ってしまうため。__dirnameをつけることで、electron .で実行しても、コードのあるフォルダの直下を読みに行ってくれる。一方で、ビルド版の場合、コード類はapp以下に収められてるけれど、user.jsonを生成するとexeの直下にできる為、app直下にuser.jsonが無いことになる。はじめからなるべくこの手の設定ファイルは直下ではなく、どこかフルパスの通る場所に保存するようにすると余計な混乱をせずに済むと思います。
以下はuser.jsonをマイドキュメント直下に指定する方法です。
1 2 3 4 5 |
//マイドキュメントパスを指定 //user.jsonの場所を指定する var dir_home = process.env[process.platform == "win32" ? "USERPROFILE" : "HOME"]; var docupath = require("path").join(dir_home, "Documents"); var userjson = docupath + "//user.json"; |
- userjson変数を参照するようにすれば開発でもビルドでも混乱せずに済みます。
関連リンク
- Microsoft Graph APIをnode.jsから使う
- Electron + Express + React で Web・デスクトップ共通のアプリを作る
- Check! Node.js で Azure AD を! ~ passport-azure-ad の導入とトラブルシューティング
- Node.js+Express+Passport を使ってみた
- Microsoft Graph (Microsoft365) API のトークンを取得して更新する方法
- Node.js でサクッと作れるAzure AD社内アプリ!〜挑戦編[comm Tech Festival](2015/09/26)
- passport-azure-adとpassport-azure-ad-oauth2の違い
- Azure Active Directory の SSO 開発 (Node.js 編)
- ElectronでTwitterのOAuth認証をする
- [node.js]express-sessionでセッションを使用してみた
- Expressにおけるejsの使い方
- Electron終了時にCacheを消す
- Passport.js - Error: failed to serialize user into session
- maliksahil/expressjs-passport-azure-ad
- Delete all Cookies in Electron desktop app
- electron cookie to remember password