electronでAzure AD認証を行い、Graph APIを叩く - 準備編

社内で利用しようとしてるとあるMicrosoftの製品。しかし、現場に浸透せずこのままでは朽ちていくだけ。殆どの企業で言える事なのだけれど、入れる事がゴールになっていて、現場の利用者のフォローや研修、また利用がしやすい(またはそうなるような動機づけとなるもの)を一切やらないとこうなるという典型例です。

しかし、使え!!と言ったり、強制!!といった強権を発動しても普及はしません。

そこで、現場のレベルに合わせて、まずは一部の機能だけで運用出来るように、クライアントアプリ@electronを作る事になりました。ウェブサービスの悪い点はフル機能をいきなり全面に全部出してしまう事ですね。あれなんとかならないのだろうか(天丼が食べたいのに、天ぷら懐石フルコースとか出されても困る)。

今回使用するプロジェクトとモジュール

この他に、認証に必要なMicrosoft Azure-ADおよびMicrosoft Graph APIも使います。GASの時にも利用してるので合わせて参照してみてください。

事前準備

Azureでプロジェクト作成

ちょっと前までは、Application Registration Portalからアプリの登録ができたのですが、現在はMicrosoft Azureの一部になっています。Microsoft365アカウントでなくとも、無料アカウントで始められるようになっているので、Microsoftアカウントが無い人はまずアカウントを作って、サインインしましょう。

さて、アプリの登録とそれに伴うクライアントIDなどの取得手順は以下の通り。

  1. アプリの登録にて登録を開始する
  2. アプリケーションの登録をクリックする
  3. 名前を入力、リダイレクトURIは「webを選択しhttp://localhost:3000/auth/callback」を入力(127.0.0.1はNG)
  4. 登録ボタンをクリックする
  5. 出てきた中で、「アプリケーション(クラと書かれているのがクライアントID」なので、このコードをメモしておく
  6. 左サイドバーより、「証明書とシークレット」をクリック
  7. 新しいクライアントシークレット」をクリックする
  8. 今回は特に有効期限を設けないで追加をクリック
  9. これで値に「クライアントシークレット」が生成されて手に入りました。このシークレットはこの時だけしか表示されないので、注意してください。
  10. つづけて、左サイドバーより「APIのアクセス許可」をクリックする
  11. Microsoft APIの中にある「Microsoft Graph」をクリックする。
  12. 委任されたアクセス許可」をクリックする
  13. デフォルトでUser.ReadがすでにONなので、offline_accessFiles.ReadWriteを検索してONにしましょう。
  14. アクセス許可の追加をクリックする
  15. 追加出来たら、xxxxxに管理者の同意を与えますをクリックします。すると、状態が緑色になります(同意を与えずとも管理者権限を要さない場合ならば使うことが可能です。この場合毎回要求されてるアクセス許可が出るようになります)
  16. 次に左サイドバーより「認証」をクリック
  17. 暗黙の付与にて、「アクセストークン」にチェックを入れる
  18. サポートされているアカウントの種類に於いては、「マルチテナント」にしておきました(企業内で使う場合にはその組織のテナントで良いでしょう)
  19. 保存をクリック
  20. 概要のエンドポイントをクリックすると、いろいろなエンドポイントURLが出る。
  21. 概要のディレクトリ(テナントの数値はメモっておきます。あとでプログラム中で使用します。

※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をグローバルインストールする

いつもならば、npm init -yでプロジェクトを作る所ですが、今回は以下のコマンドでexpress-generatorを使ってプロジェクトを作成します。今回はazureというディレクトリを作って作業をします。

azureというディレクトリが作られて、中にapp.js他たくさんの何かが生成されます。この段階で、以下のコマンドでテストをしてみます。

Windowsの場合、Firewallが作動して許可するかどうかを聞いてくるので、プライベートネットワークにチェックを入れてアクセスを許可するにチェックを入れる。そして、Chromeなどでhttp://localhost:3000/にアクセスした場合に、welcome to expressが表示されれば成功です。Ctrl+Cでnpm startしたサーバを停止できます。

※企業などでクライアントPCでこのアクセス許可が許されていない場合には、別途expressだけで認証用サーバを立てて、electronからそのサーバへアクセスする方式にしておけば、クリアできます。

図:expressによるWebサーバが立ち上がる際の画面

続けて、app.jsの入ってるフォルダに空のindex.jsを作成し、以下のコマンドで他のモジュールをインストールしておきます。

生成されているpackage.jsonを開いて、"main": "index.js",を追記して保存します。以下のような感じになるはずです。

index.jsにとりあえず以下のテスト用コードを追記してみる。npm startでexpressのサーバを起動した状態で、electron .で起動してみて、welcome to expressが表示されればテストはOK。

しかし、このままでは別途npm startでexpressを立ち上げて置いてから、electron .で起動する必要があるため、package.jsonscriptsのラインにnpm-run-allを使って複数のタスクを同時に起動するように細工をします。これで、テスト時は、electron .だけで全て起動します。

図:expressサーバをelectronから開けた

ログイン画面とトークンの取得

ここからが一番の峠道です。通常であればindex.jsに色々と記述する所ですが、今回の認証アプリの場合には、app.jsの方に記述する事になります。自動生成されたapp.jsにはすでに色々記述されているので、これを改造して使います。

今回は、こちらのソースコードを利用しています(少し改変を加えています)

ソースコード

app.js側コード

  • テナントの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を実行すると、signinsingoutの2つが出てきます。サインインをクリックするとelectron内でMicrosoftのログイン画面が出てきて、メールアドレスとパスワードを入力します。

サインイン時にScopeで設定したものについて承認を求められるので、「承諾」とする事で、認証が実行されて、Access Tokenなどが取得出来るようになります。

図:Microsoftのログイン画面が出てきた

図:承諾をする事でトークンが返却される

注意点

ログインクッキー

ログイン画面に於いて、パスワードを保存等すると、electronにcookieとして保存されます。よって、ここで保存されてしまうと次回以降ログイン時に利用されるので、ログイン画面が出てこなくなりますが、そのcookieは、expressが保存してるものではなく、electronが保持しています。

このcookieはキャッシュクリアではクリアされません。Windowsの場合保存されているパスは以下の場所で、CookiesというSQLiteファイルに格納されています。

アプリの名称は、package.jsonに記述されているname属性の名前が利用されています。このディレクトリにあるCookiesを削除すれば、再度ログインが出来るようになります。しかし、これを手動でやっていては非常に面倒なので、singoutを実行したら、クリアするコードをapp.getの/auth/signoutに追加実装します。

  • 冒頭で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関数を以下のように単純化したところ、問題なく動きました。

アクセストークンについて

今回は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に対して挙動を設定します。

  • 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をマイドキュメント直下に指定する方法です。

  • userjson変数を参照するようにすれば開発でもビルドでも混乱せずに済みます。

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)