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 i express-generator -g

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

express azure
cd azure
npm install

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

npm start

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

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

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

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

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",を追記して保存します。以下のような感じになるはずです。

{
  "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。

'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.jsonscriptsのラインにnpm-run-allを使って複数のタスクを同時に起動するように細工をします。これで、テスト時は、electron .だけで全て起動します。

"scripts": {
    "start": "node ./bin/www",
    "electron": "electron .",
    "dev": "npm-run-all --parallel electron start"
  }

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

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

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

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

ソースコード

app.js側コード

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

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

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

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

注意点

ログインクッキー

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

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

C:\Users\ユーザ名\AppData\Roaming\アプリの名称

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

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

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

//自動起動設定を初期化
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をマイドキュメント直下に指定する方法です。

//マイドキュメントパスを指定
//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変数を参照するようにすれば開発でもビルドでも混乱せずに済みます。

関連リンク

コメントを残す

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

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