Google Apps ScriptからAzure Entra IDのユーザ情報読み書き【GAS】

Microsoft Azure ADことEntra IDは導入してIdPとして使ってるけれど、Google Workspaceを使っているという場合、プロビジョニングで自動連携してるケースがあります(Entra側にユーザ追加や更新すると自動でGWS側も反映する)。その際に組織部門の値をもって同期するとユーザの組織部門配置を書き換えが可能です。

しかし、ユーザ数が多い場合や定期異動、転勤等で配置転換が起きるとEntra ID側のプロパティをいちいち開いて手修正なんてやってられません。そこでこれをスプレッドシート上のデータを元に一括でGraph APIを利用してEntra IDのユーザデータを読み書きを出来るようにしてみました。

今回利用するスプレッドシート

利用する為には事前準備が必要です。また、今回はFAX番号に対してGWS側のOrgUnitPathの値を格納するというシーンを想定しています。これは過去記事の以下のエントリーにて、ユーザプロパティの値をもって同期を掛けるというシーンで使えるものになります。

ただ、汎用的なコードですので、コードを色々書き換えれば、例えば人事管理システムからデータを引き抜いて、それをEntra ID側に反映するなんて使い方も出来るでしょう。

Entra IDとGoogle Workspaceのメンバーを同期させる方法 - Provisioning編

事前準備

GAS側の準備

ライブラリの追加

以下の手順でOAuth2 for Apps Scriptライブラリを追加しましょう。

  1. スクリプトエディタを開きます。
  2. 左サイドバーのライブラリの+をクリックします
  3. ライブラリを追加欄に「1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF」を追加します。
  4. 今回はバージョンは43を選択してみます。
  5. 保存ボタンを押して完了

図:OAuth2ライブラリの追加を行っておく

コールバックURLを作っておく

コールバックURLとは、認証を完了しAccess Tokenを取得したら戻るべきURLを指定するものです。これは、スクリプトIDをもとに作られているので、スクリプトIDを取得して組み立てます。

  1. スクリプトエディタの左サイドバーより、プロジェクトの設定を開く
  2. 情報の中にある「スクリプトID」をコピーする。
  3. https://script.google.com/macros/d/スクリプトID/usercallback として組み立てる。これがコールバックURLとなる。

このコールバックURLをAzure側の準備で利用します。

図:スクリプトIDを元に構築する

組織部門

今回はEntra ID側での値の反映までしか見ませんので参考情報ですが、Entra ID側に/GWSと/CIFという2つの値を格納する予定です。これは管理コンソールに於いてユーザの組織部門配置で利用する値で、OrgUnitPathに該当します。この値ですが、組織部門のルートが「/」から始まるので、その下の組織部門がGWSならば「/GWS」という値を格納する必要があります。

よって、利用する場合には上記のルールを把握して、Entra ID側の今回はFAX番号欄へと記入するのが目的です。(別にFAX番号でなくとも良いのですが)。

Azure側の準備

Azure側に入って、アプリを作成します。

  1. アプリの登録にて登録を開始する
  2. 新規登録をクリックする
  3. 名前を入力(今回はentraidmanと入力しました)、リダイレクトURIは「webを選択しhttps://script.google.com/macros/d/スクリプトID/usercallback」を入力
  4. 登録ボタンをクリックする
  5. 出てきた中で、「アプリケーション(クラと書かれているのがクライアントID」なので、このコードをメモしておく
  6. 左サイドバーより、「証明書とシークレット」をクリック
  7. 新しいクライアントシークレット」をクリックする
  8. 今回は6ヶ月の期限を選択するのでそれをクリック
  9. これで値に「クライアントシークレット」が生成されて手に入りました。このシークレットはこの時だけしか表示されないので、注意してください(シークレットIDではないので注意)
  10. つづけて、左サイドバーより「APIのアクセス許可」をクリックする
  11. Microsoft APIの中にある「Microsoft Graph」をクリックする。
  12. 委任されたアクセス許可」をクリックする
  13. デフォルトでUser.ReadがすでにONなので、今回はopenid、email、offline_access、User.ReadWrite.All、Directory.ReadWrite.Allを検索してONにしましょう。
  14. アクセス許可の追加をクリックする
  15. 追加出来たら、xxxxxに管理者の同意を与えますをクリックします。すると、状態が緑色になります。
  16. 次に左サイドバーより「認証」をクリック
  17. 暗黙の付与にて、「アクセストークン」にチェックを入れる
  18. サポートされているアカウントの種類に於いては、「マルチテナント」にしておきました。
  19. 保存をクリック
  20. 概要のエンドポイントをクリックすると、いろいろなエンドポイントURLが出る

図:Graphを選択する

図:アクセス権限付与した状態

図:認証の設定変更に注意

ソースコード

今回は、FAX番号の読み書きをするので利用するAPIはBetaであることに注意が必要です。取得しておいたアプリケーションIDやクライアントシークレット、Graph APIで許可したAPIのスコープ情報を記述する必要があります。

認証を行うコード

認証実行はstartoauthを叩くのですが、事前にウェブアプリケーションとしてデプロイしておく必要があります。

GAS側コード

Authorize.gsというファイルに記述しています。今回は便宜的にここの上部にアプリケーションIDや、クライアントシークレットを書き足しますが、本番運用時はこれらをスクリプトプロパティに格納して流出しないよう厳格に管理する必要があります。

//認証用の各種変数
var appid = 'ここにアプリケーションIDをいれる';
var appsecret='ここにクライアントシークレットをいれる';
var scope = "User.Read offline_access Directory.ReadWrite.All User.ReadWrite.All"
var tokenurl = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
var authurl = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"

//OAuth認証開始
function startoauth(){
  //UIを取得する
  var ui = SpreadsheetApp.getUi();
  
  //認証済みかチェックする
  var service = checkOAuth();
  if (!service.hasAccess()) {
    //認証画面を出力
    var output = HtmlService.createHtmlOutputFromFile('template').setHeight(450).setWidth(500).setSandboxMode(HtmlService.SandboxMode.IFRAME);
    ui.showModalDialog(output, 'OAuth2.0認証');
  } else {
    //認証済みなので終了する
    ui.alert("すでに認証済みです。");
  }
}

//アクセストークンURLを含んだHTMLを返す関数
function authpage(){
  var service = checkOAuth();
  var authorizationUrl = service.getAuthorizationUrl();
  var html = "<center><b><a href='" + authorizationUrl + "' target='_blank' onclick='closeMe();'>アクセス承認</a></b></center>"
  return html;
}

//認証チェック
function checkOAuth() {
  return OAuth2.createService("Graph")
    .setAuthorizationBaseUrl(authurl)
    .setTokenUrl(tokenurl)
    .setClientId(appid)
    .setClientSecret(appsecret)
    .setScope(scope)
    .setCallbackFunction("authCallback") //認証を受けたら受け取る関数を指定する
    .setPropertyStore(PropertiesService.getScriptProperties())  //スクリプトプロパティに保存する
    .setParam("response_type", "code");
}

//認証コールバック
function authCallback(request) {
  var service = checkOAuth();
  console.log(request);
  var isAuthorized = service.handleCallback(request);
  if (isAuthorized) {
    return HtmlService.createHtmlOutput("認証に成功しました。ページを閉じてください。");
  } else {
    return HtmlService.createHtmlOutput("認証に失敗しました。");
  }
}

//ログアウト
function reset() {
  checkOAuth().reset();
  SpreadsheetApp.getUi().alert("ログアウトしました。")
}
  • 今回利用するGraph APIのエンドポイントはhttps://graph.microsoft.com/beta/usersとなります。
  • 要求する権限はscopeに半角スペースで区切って、Entra ID側で用意したものと同じものを設定します。
  • startoauthを実行して認証を実行すれば、スクリプトプロパティにAccess Tokenが格納されます。

HTML側コード

template.htmlとして記述しています。OAuth認証を行い結果を受け取って表示するだけに利用します。

<link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons.css">
<script type="text/javascript" src="https://apis.google.com/js/api.js"></script>
<script>
    google.script.run.withSuccessHandler(onSuccess).authpage();

    function onSuccess(data) {
        document.getElementById("kinoko").innerHTML = data;
    }

</script>

<style type="text/css">
    /* --- ボックス --- */
    div.section {
        width: 480px;
        /* ボックスの幅 */
        background-color: #ffffff;
        /* ボックスの背景色 */
        border: 1px #c0c0c0 solid;
        /* ボックスの境界線 */
        font-size: 100%;
        /* ボックスの文字サイズ */
    }

    /* --- 見出し --- */
    div.section h3 {
        margin: 0;
        /* 見出しのマージン */
        padding: 6px 10px;
        /* 見出しのパディング(上下、左右) */
        background-color: #f5f5f5;
        /* 見出しの背景色 */
        border-bottom: 1px #c0c0c0 solid;
        /* 見出しの下境界線 */
        font-size: 120%;
        /* 見出しの文字サイズ */
    }

    /* --- ボックス内の段落 --- */
    div.section p {
        margin: 1em 10px;
        /* 段落のマージン(上下、左右) */
    }
</style>

<div class='section'>
    <img border="0" src="https://officeforest.org/library/oauth2.png" alt="oauth2">
    <h3 id='header'>OAuth認証の許可が必要です。</h3>
    <hr>
    <div id="info">
        <p>
            このスクリプトは、Microsoft Graph APIにアクセスするために、特別なログイン処理を利用しています。<br>
            既に特別なログインに関する設定はなされており、承認がされるとプログラムを実行することが出来ます。この承認がなされない場合、プログラムの実行に制限が掛かり、
            処理が続行できません。<br><br>

        <div id="kinoko"></div>
    </div>
    <p>
        <script>
            function closeMe() {
                if (google && google.script && google.script.host) {
                    google.script.host.close();
                } else if (window && window.close) {
                    window.close();
                }
            }
        </script>
</div>
  • 実際にこれらのコードで、startoauthを実行すると、スプレッドシート上で認証用のダイアログが出ます。
  • 認証でEntra IDアカウントにログインします(もしくは作成したonmicrosoft.comのアカウント)
  • 取得したAccess Tokenほかはスクリプトプロパティのoauth2.Graphという項目にガッツリ値が格納されます。ここにはAccess Token, Refresh Token, expire_inのタイムなどが入っています。
  • reset関数はログアウトされて、再度認証ができるようになります。

読み書きするコード

ユーザリスト読み出し

entraid.gsに記述しています。Entra ID側に登録されてるユーザの情報のうち、一部だけを取り出してスプレッドシートに一括書き出ししています。

//Graph API Endpoint
//OrgUnitPathを取得するため、Betaのエンドポイントを利用する
var endpoint = "https://graph.microsoft.com/beta/users";

//Entra IDユーザを
function getEntraUsers() {
  //UIを取得する
  let ui = SpreadsheetApp.getUi();

  //Graph APIサービスを取得する
  var service = checkOAuth();

  if (service.hasAccess()) {
    //HTTP通信
    var response = UrlFetchApp.fetch(endpoint, {
      headers: {
        Authorization: "Bearer " + service.getAccessToken()
      },
      method: "GET",
      contentType: "application/json"
    });

    //取得したデータをパースする
    let result = JSON.parse(response.getContentText())
    
    //データ部分だけ取り出す
    let data = result.value;
    
    //必要な項目だけをピックアップする
    let array = [];  //書き込み用の配列

    for(let i = 0;i<data.length;i++){
      //レコードを一個取り出す
      let rec = data[i];

      //一時配列を用意する
      let temparr = [];

      //必要な項目をpushする
      temparr.push(rec.id);
      temparr.push(rec.userPrincipalName);
      temparr.push(rec.displayName);
      temparr.push(rec.surname);
      temparr.push(rec.givenName);

      //OrgUnitPathとしてFax番号の場所を使ってるのでそれを取得
      temparr.push(rec.faxNumber);

      //書き込み用配列にpushする
      array.push(temparr)
    }

    //entraシートの中身をクリアする
    let ss = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("entra")
    ss.getRange("A2:F").clearContent();

    //一括書き込み
    let lastColumn = array[0].length;   //カラムの数を取得する
    let lastRow = array.length;         //行の数を取得する
    ss.getRange(2,1,lastRow,lastColumn).setValues(array); 

    //終了表示
    ui.alert("💡 書き出し完了!!")

  }else{
    //エラーを返す(認証が実行されていない場合)
    ui.alert("🙅‍♀ エラーみたいです。");
  }
}
  • OrgUnitPathはFAX番号を今回使ってるのでそこの値も取ってきています。

ユーザ情報一括反映

entraid.gsに記述しています。上記とは反対にスプシ側の記述情報を書きに行っていますが、今回はFAX番号のみ書き込みにしています。

//Entra IDユーザ情報を一括書き換え
function setEntraUsers(){
    //UIを取得する
  let ui = SpreadsheetApp.getUi();

  //Graph APIサービスを取得する
  var service = checkOAuth();

  //Graph APIサービスを取得する
  var service = checkOAuth();

  if (service.hasAccess()) {
    //スプシのデータを取得する
    let ss = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("entra").getRange("A2:F").getValues();

    //リクエスト本文用データを構築する(1個ずつ処理)
    for(let i = 0;i<ss.length;i++){
      //レコードを取得する
      let rec = ss[i];

      //空レコードはスルーする
      if(rec[0] == "" || rec[0] == undefined){
        continue;
      }

      //OrgUnitPathの値を確認(空ならば既定値をいれる)
      let orgunitpath = "";
      if(rec[5] == "" || rec[5] == undefined){
        orgunitpath = "/CIF";
      }else{
        orgunitpath = rec[5];
      }
      
      //一時連想配列を用意して構築
      let temparr ={
        faxNumber: orgunitpath
      }
      
      //エンドポイントを構築する
      let url = endpoint + "/" + rec[0];
      
      //HTTPリクエスト
      var response = UrlFetchApp.fetch(url, {
        headers: {
        Authorization: "Bearer " + service.getAccessToken()
        },
        method: "PATCH",
        contentType: "application/json",
        payload: JSON.stringify(temparr),
        muteHttpExceptions : true
      });
      
      //レスポンスを取得する
      let res = response.getContentText();
      let status = response.getResponseCode();

      //ステータスコードで処理を分岐
      if(status == 204){
        //無事反映時
        //スリープをいれる(0.3秒間)
        Utilities.sleep(300)
      }else{
        //エラー時の処理
        ui.alert("🙅‍♀ エラーみたいです。\n\n" + res.error.message);
        return;
      }
    }

    //終了表示
    ui.alert("💡 Entra更新完了!!")

  }else{
    //エラーを返す(認証が実行されていない場合)
    ui.alert("🙅‍♀ エラーみたいです。");
  }
}
  • 値がNullのセルの場合は、/CIFの値を書き込む用に指定しています。
  • リクエストはPATCHにて指定が必要です。
  • 無事に書き込みができるとレスポンスコードは204が返ってきます。
  • UrlfetchAppの連続実行制限をスルーするためにUtilities.sleepを0.3秒間入れています(単位:ms)。

図:無事に書き込めました。

注意事項

今回のコードではEntra IDのユーザはテスト用に用意した数件でしか検証していません。しかし、Entra ID側ユーザ数が膨大な場合、GASの連続実行時間6分の規制に掛かる可能性があります。

その場合、以下の6分の壁を超えるためのテクニックを利用して突破できるようにしてあげる必要があります(ウェイト処理は1件処理する毎0.3秒入れていますが、UrlfetchAppの429エラー対策の為ですので外すことは出来ません)。

UrlfetchApp.fetchAllにてまとめてリクエストを投げることが可能ですが、Graph API側は1度のリクエストで20件まで当時にバルク処理が出来るようです。

Google Apps Scriptで6分の壁(タイムアウト)を突破する【GAS】

Google Apps Scriptで6分の壁(タイムアウト)を突破する - 番外編【GAS】

関連リンク

コメントを残す

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

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