GASライブラリで認証を共通化したら空のDBが397個できた|API方式に移すまで

複数のGASアプリのログインを1つにまとめた Google Workspace・GAS

Google Apps Script(GAS)でWebアプリを3つ4つと作っていくと、アプリごとに「使ってよい人のリスト」を持つことになります。わたしはこれを1か所にまとめようとして、共通の認証ライブラリを作りました。最初はうまく動いていましたが、ある日、登録したアカウントとグループが数分おきに消えるようになり、Googleドライブには空のスプレッドシートが397個できていました。

先に結論を書きます。

  • 認証の情報(誰がどのアプリを使えるか)を複数のアプリで共有するなら、ライブラリではなく、デプロイした人の権限で動く小さなAPIにする
  • ライブラリは、呼び出したアプリの実行ユーザーの権限で動く。executeAs: USER_ACCESSING のアプリから呼ぶと、利用者全員がDBを編集できないと動かない
  • 「開けない」エラーを「存在しない」と見なしてデータを作り直すコードは書かない。今回の事故の直接の原因はここだった

GAS Webアプリの認証方式そのもの(getActiveUser() が空になる条件など)はGAS Webアプリの認証|getActiveUserが空になる時の判断表にまとめています。この記事は、その認証を複数のアプリで共通にするときの話です。

やりたかったこと

アプリが増えると、次の作業がアプリの数だけ発生していました。

  • 新しい人を追加するたびに、各アプリの許可リストを直して再デプロイする
  • 「このグループの人は全部のアプリを使える」という指定ができない
  • 管理者かどうかの判定が、アプリごとに少しずつ違う

前提として、わたしは仕事の道具をGoogle Workspaceのサービスだけで完結させると決めています。外のサービスは足さず、Workspaceに足りないところはGASで自分用の仕組みを作って補う、という方針です。

GASのWebアプリの公開範囲は「自分だけ」「Googleアカウントを持つ全員」「全員」のような大きな単位でしか選べず、アプリをまたいで「この人とこのグループは使ってよい」を1か所で管理する仕組みはありません。共通ログインは、その足りない部分を補うために作りました。

具体的には、許可リストを1枚のスプレッドシート(以下「認証DB」)に置き、各アプリは共通のライブラリに「この人は使ってよいか」を聞くだけ、という形にしました。登録はメールアドレス単位とGoogleグループ単位の両方に対応させ、登録を編集する管理画面も作りました。

共通ログインの管理画面。左に連携しているアプリ、右に全アプリ共通の設定(ダミーデータ)

アプリごとの設定画面。登録アカウントと権限を1か所で管理する(ダミーデータ・当時の画面)

当時の画面(アプリごとの登録画面は、その後なくしました。末尾の「その後」を参照)

何が起きたか

ライブラリに切り替えてしばらくすると、管理画面から登録アプリ・アカウント・グループが消えるようになりました。入れ直しても、数分後にまた消えます。ドライブを見ると、中身が空の認証DBが大量にできていました。

原因は2つ重なっていました。

1. 権限の前提: 各アプリは「アクセスした本人の権限で動く」設定(USER_ACCESSING)でした。ライブラリのコードも、その本人の権限で動きます。認証DBを共有されていない人がアプリを開くと、SpreadsheetApp.openById() が必ず権限エラーになります
2. エラーの扱い: ライブラリは、DBを開けなかったときに「まだDBがない」と判断し、新しい空のDBを作って参照先を書き換える作りでした

この2つが組み合わさると、次の往復が起きます。

  • Aさんがアプリを開く → Aさんが作ったDBが参照先になる
  • Bさんが開く → AさんのDBを開けない → Bさんが新しいDBを作り、参照先を上書き
  • Aさんが開く → BさんのDBを開けない → また新しいDBを作る

しかも、アプリの1つは画面を開いている間、3〜5分おきに自動でサーバーを呼ぶ作りでした。ブラウザのタブを開きっぱなしにしていたので、誰も操作していなくてもこの往復が回り続け、丸1日で空のDBが397個になりました。

まず止めた(その日のうちの対応)

  • 認証DBのIDをコードに固定した(参照先をプロパティで書き換えない)
  • DBを開けないときは、作り直さずにエラーで止めるようにした
  • 各アプリで、認証が使えないときに生のGASエラーではなく「原因と対処」を書いた案内ページを出すようにした
  • 消えた登録は、消える直前のDBから一度だけ流し込んで戻した
  • 空のDBはゴミ箱へ移した(安定を確認するまで完全削除はしない)

これで往復は止まります。ただし「利用者全員に認証DBの編集権限を渡す」という前提は残ります。これは、使う人なら誰でも許可リストを直接書き換えられるという意味でもあります。認証の仕組みとしては良くない状態です。

API方式に移した

根本の対策として、認証の部分をライブラリから独立したWebアプリ(API)に移しました。

項目 ライブラリ方式 API方式
認証DBに触るアカウント アプリを開いた利用者全員 デプロイした人1人だけ
利用者に必要な権限 認証DBの編集権限 なし
認証の更新を反映する手順 ライブラリの新しい版を作る → 各アプリの設定で版を上げる → 各アプリを再デプロイ APIを再デプロイするだけ
呼び出しの速さ 速い(同じ実行の中) HTTPの往復が1回増える(結果を短時間キャッシュして補う)

APIの側の設定は、いまは次のとおりです(移した直後は access: DOMAIN でした。変えた経緯は末尾の「その後」に書きます)。

{
  "webapp": {
    "executeAs": "USER_DEPLOYING",
    "access": "ANYONE_ANONYMOUS"
  }
}
  • executeAs: USER_DEPLOYING で、認証DBにはデプロイした人の権限で触る
  • access: ANYONE_ANONYMOUS で、HTTPの入口ではGoogleのログインを求めない。そのかわり、リクエストの本文に入った利用者本人のアクセストークンをAPI側でGoogleのtokeninfoに問い合わせて検証し、そのトークンの持ち主を「呼び出した人」とする。トークンがない・無効なリクエストは、データに触る前に拒否する
  • 呼び出し側が申告したメールアドレスは使わない。本人の判定は、Googleが返すメールアドレスだけで行う
  • 移した直後の access: DOMAIN では、API側の Session.getActiveUser().getEmail() で呼び出した人のメールアドレスが取れることを確認していた(同じドメインの利用者の場合)。ただし個人Gmailの利用者では取れない

各アプリの側は、UrlFetchApp で利用者本人のアクセストークンを本文に入れてAPIを呼ぶ小さなクライアントを持つだけです。

// 各アプリに置くクライアント(例)
function checkAccess_(appId) {
  const cache = CacheService.getUserCache();
  const hit = cache.get('access:' + appId);
  if (hit) return JSON.parse(hit);

  const url = PropertiesService.getScriptProperties().getProperty('AUTH_API_URL');
  const res = UrlFetchApp.fetch(url, {
    method: 'post',
    contentType: 'application/json',
    // トークンはヘッダーではなく本文に入れる。API側がtokeninfoで検証する
    payload: JSON.stringify({
      action: 'checkAccess',
      appId: appId,
      accessToken: ScriptApp.getOAuthToken(),
    }),
    muteHttpExceptions: true,
  });
  if (res.getResponseCode() !== 200) {
    // 開けない=「未登録」と決めつけない。原因が分かる形で止める
    throw new Error('認証APIに接続できません: ' + res.getResponseCode());
  }
  const result = JSON.parse(res.getContentText());
  cache.put('access:' + appId, JSON.stringify(result), 300); // 5分だけ覚える
  return result;
}

API側では、本文のトークンを次のように検証します。

// API側(例): 本文のトークンから呼び出した人を決める
function resolveCaller_(req) {
  const token = String((req && req.accessToken) || '');
  if (!token) return ''; // トークンなしは受け付けない
  const res = UrlFetchApp.fetch(
    'https://oauth2.googleapis.com/tokeninfo?access_token=' + encodeURIComponent(token),
    { muteHttpExceptions: true });
  if (res.getResponseCode() !== 200) return '';
  const info = JSON.parse(res.getContentText());
  if (String(info.email_verified) !== 'true') return '';
  if (!(Number(info.expires_in) > 0)) return ''; // 期限切れのトークンは拒否する
  // 自分のアプリで発行されたトークンだけを受け付ける。
  // ALLOWED_AUDIENCES には、呼び出し元アプリのOAuthクライアントIDをカンマ区切りで入れておく
  const allowed = String(PropertiesService.getScriptProperties()
    .getProperty('ALLOWED_AUDIENCES') || '')
    .split(',').map(function (s) { return s.trim(); }).filter(Boolean);
  const aud = String(info.azp || info.aud || '');
  if (allowed.length && allowed.indexOf(aud) < 0) return '';
  return String(info.email || '').toLowerCase(); // 空なら呼び出し元で拒否する
}

呼び出し元のアプリのマニフェストには、https://www.googleapis.com/auth/script.external_request(APIを呼ぶため)と https://www.googleapis.com/auth/userinfo.email(tokeninfoがメールアドレスを返すため)のスコープが必要です。

ヘッダーに Authorization: Bearer を付けて、ログインが必要な公開範囲(「Googleアカウントを持つ全員」など)のWebアプリを呼ぶ方法もあります。わたしの環境では、トークンにDriveのスコープが入っていないと、GASの入口でHTTP 401になりました。利用者にDriveの権限まで求めたくなかったので、入口はログインなしにして、本人確認は本文のトークンで行う形にしています。

切り替えの順番

本番を止めずに移すため、次の順で進めました。

1. APIを新しく作ってデプロイし、URLを控える
2. 各アプリのスクリプトプロパティにAPIのURLを入れ、エディタから疎通テスト用の関数を実行する。「接続できた」「自分のメールアドレスが返った」の2つを確認する
3. 各アプリのコードをAPIクライアント経由に書き換えて保存する。本番(/exec)は固定の版で動いているので、この時点では本番に影響しない
4. 1分おきに動く時間主導トリガーを「カナリア」にした。トリガーは保存済みの最新コードで動くので、本番の画面より先に新しい経路を通る。認証DBの「最終アクセス時刻」がAPI経由で更新されたことを確認した
5. 各アプリのマニフェストからライブラリへの依存を外し、本番を再デプロイする

残っている作業: 認証DBから利用者の編集権限を外す(まだ済んでいない)。API方式では利用者に編集権限は要らないので、外しても動く想定です。外すまでは、ライブラリ方式のときの「使う人なら誰でも許可リストを書き換えられる」状態が残っています。

4の「トリガーをカナリアにする」は、GASで本番を止めずに新しいコードを試す方法として、ほかの場面でも使えます。

ついでに共通化できたもの

認証をAPIにすると、アプリをまたいで持ちたい小さな設定も同じ場所に置けるようになりました。いまは画面のテーマをAPIで共有していて、どのアプリを開いても基本的に同じ配色になります(配色を固定しているアプリは除く)。

APIが持っているのは、選ばれているテーマの名前と、配色の定義そのものです。アプリごとにCSSの変数名の付け方が違ったので、APIは同じ配色を変数名の付け方ごとに返し、各アプリは自分に合うほうを受け取って画面に流し込むだけにしました。テーマを1つ足すときも、APIの側に1行足せば、アプリのコードは触らずに全アプリで選べます。

テーマを変えられるのは管理画面だけです。各アプリからは変えられません。全アプリ・全員に効く設定なので、変える場所を1か所に絞りました。

判断表: ライブラリとAPIのどちらにするか

共有したいもの おすすめ 理由
関数・計算だけ(データを持たない) ライブラリ 権限の問題が起きない。呼び出しが速い
全員が読んでよいデータ(読み取りのみ) ライブラリでも可 利用者全員に閲覧権限を渡す前提なら成り立つ
認証・権限・利用者ごとの記録(書き込みあり) API 利用者にDBの編集権限を渡さずに済む
利用者に個人Gmailが混ざる API+トークンの検証 access: DOMAIN と getActiveUser() が使えない。本文のトークンをtokeninfoで確かめる(下の「その後」と前の記事の判断表を参照)

わかったこと

  • GASのライブラリは「コードを共有する仕組み」で、「権限を分ける仕組み」ではない。呼び出したアプリの実行ユーザーの権限で動く
  • 見つからない・開けないというエラーは、権限の不足、一時的な制限、本当に存在しない、のどれなのか区別できない。区別できないエラーで自動的に作り直さない
  • 画面を開いている間ずっと自動更新するアプリは、裏で起きている不具合を何倍にも増やす。今回、丸1日で397回分の往復が起きたのはこのため

同じ構成のアプリがあと2つあるので、次はノート・マインドマップ・ToDoを1つのアプリにまとめた話を書く予定です。

その後

APIに移したあとも、公開範囲は2回変えています。

1. 移した直後(access: DOMAIN): 同じWorkspaceドメインの人だけが呼べる。API側の getActiveUser() で呼び出した人が分かる
2. 9月23日から(access: ANYONE+tokeninfo): 登録したGoogleグループに個人Gmailのメンバーがいて、getActiveUser() ではその人を特定できない。本文に入れたアクセストークンをtokeninfoで検証する方式に変えた
3. 10月3日から(ログインなしの入口+トークン必須): 入口を ANYONE_ANONYMOUS にした。データに触る操作は、検証できたトークンと登録の両方がないと通らない。トークンなし・無効なトークンのリクエストが実際のHTTPで拒否されることを確認した。利用者にDriveの権限を求めないための構成

同じ時期に、アプリごとの利用者登録の画面はなくしました。登録は、全アプリ共通のGoogleグループとアカウントにまとめています。

登録済みの管理者が管理画面に入れなくなった件と、APIと管理画面で実行ユーザーを分けた話は、翌日の記事「GASのUSER_DEPLOYINGとUSER_ACCESSINGを分けた|管理画面に管理者が入れない」に書きます。

関連記事:

コメント

タイトルとURLをコピーしました