GAS Webアプリの認証|getActiveUserが空になる時の判断表

アイキャッチ Google Workspace・GAS

GAS(Google Apps Script)のWebアプリを「決まった人だけ」に見せるには、実行するユーザー・利用者の承認・メールアドレスを取得できる条件を分けて確認します。わたしは取得できなかった経験を、ドメインだけで決まる制限として説明していました。この判断表では、その断定を訂正します。

  • Session.getActiveUser().getEmail() で利用者のメールアドレスを取得できる場合は、許可リストと照合する。空文字なら許可しない
  • 個人Gmailや別ドメインだから必ず空になる、とは判断しない。実行者と承認状態を確認する。URLキーは本人確認の代わりにはならない
  • どの方式でも、ScriptApp.getService().getUrl() が返すドメイン専用URL(/a/ドメイン名/macros/...)は正規化してから使う

ハマった経緯は前の記事に書きました。この記事は、これから作る人が判断と設定をそのまま進められるように、表と手順だけにまとめたものです。

判断表: 取得条件と必要な制限で方式を考える

確認した条件・目的 方式の候補 注意点
利用者のメールアドレスを取得できる getActiveUser() のメールアドレスを許可リストと照合 空文字は拒否。コードに埋めた許可リストの変更には再デプロイが必要
本人確認が不要で、共有鍵だけの制限を許容できる URLに秘密のキーを付ける(?key=...) URLが漏れれば鍵を知る人が通れる。機密情報の本人確認には使わない
複数アプリで本人確認と利用許可の管理をまとめたい 複数のGASアプリで共有する認証の仕組み(登録制)に寄せる 登録照合の前に、信頼できる本人確認が必要
人ではなく、プログラムが定期的に書き込む 閲覧用とは別のデプロイを用意し、送信側で認証情報を付ける わたしの環境では「匿名で誰でも」に広げたら401になった(後述)

わたしの当時の環境では、デプロイの実行者やOAuthスコープを変えても空文字の問題が解決しませんでした。ただし、この結果を全環境に一般化はできません。

Google公式のSessionリファレンスでは、利用者の承認なしに実行できる文脈ではメールアドレスを取得できない場合があり、「自分として実行」するWebアプリもその例に含まれます。一方、開発者自身の実行や同じWorkspaceドメインでは、通常この制限が適用されないとされています。同一ドメインなら必ず取得できる、という保証でもありません。

次に確認することは、公開中のデプロイの実行者と、対象アカウントの承認状態です。 その条件でメールアドレスが取得できるかを検証し、空文字のまま許可する処理にはしません。

手順1: メールアドレスを取得できる条件で照合する

利用者のメールアドレスを取得できることを確認した場合の最小例です。空文字が返ったときも「許可しない」側に倒します。

var ALLOWED = ['許可するアドレス1', '許可するアドレス2']; // 実際のメールアドレスを入れる

function doGet(e) {
  var email = Session.getActiveUser().getEmail();
  if (!email || ALLOWED.indexOf(email) === -1) {
    return HtmlService.createHtmlOutput('このページを見る権限がありません。');
  }
  return HtmlService.createHtmlOutput('ようこそ');
}

appsscript.json の webapp は、わたしは次の組み合わせで使っています。

"webapp": {
  "executeAs": "USER_ACCESSING",
  "access": "ANYONE"
}

ANYONE は「Googleアカウントでログインしている全員」です。ここで入口を広めに開けて、中のコードで絞る形になります。

手順2: URLキーで制限する場合は、本人確認と区別する

「誰がアクセスしているか」ではなく「そのURLのキーを知っているか」で判定します。これは本人確認ではありません。ブラウザ履歴や転送先などにキーが残る可能性もあります。機密情報を扱う場面の認証の代替にはしません。

function doGet(e) {
  var params = (e && e.parameter) || {};
  var key = PropertiesService.getScriptProperties().getProperty('VIEW_KEY');
  if (!key || params.key !== key) {
    return HtmlService.createHtmlOutput('このページを見る権限がありません。');
  }
  return HtmlService.createHtmlOutput('ようこそ');
}

利用者には .../exec?key=... の形でブックマークしてもらいます。この例のキーはスクリプトプロパティに保存しています。漏れた場合は VIEW_KEY を変更して旧キーを失効させ、新しいキーを付けたURLを配り直します。コードを変えないキー更新だけなら再デプロイは不要です。

わたしはこの方式を10日ほど使ってから、やめました。理由は2つです。

  • キーはURLごと共有されると、誰でも見られてしまう
  • 併用していたメールアドレスの許可リストは、変えるたびにコード修正と再デプロイが必要だった

手順3: 人の出入りがあるなら、共通の認証の仕組みに寄せる

いまは、複数のGASアプリで共有している認証の仕組みに、閲覧者の管理を一本化しています。そこに登録したアカウント、または登録したGoogleグループのメンバーだけが見られる形です。各Webアプリ側は「この人は登録されているか」を問い合わせて、結果が ok のときだけ画面を返します。

function isAllowedViewer_() {
  try {
    return resolveAccess_().status === 'ok'; // 共通の認証の仕組みへ問い合わせる
  } catch (err) {
    console.error('access check failed: ' + err);
    return false; // 問い合わせに失敗したら見せない
  }
}

利点は、許可する人を増やしたり外したりするときに、各アプリのコードを触らずに済むことです。問い合わせに失敗したときは「見せない」側に倒しています。

手順4: ドメイン専用URLを正規化する

画面内のリンクに ScriptApp.getService().getUrl() を使うと、Workspaceのアカウントで実行したときに https://script.google.com/a/ドメイン名/macros/s/.../exec という形が返ることがあります。この形は、別ドメインや個人Gmailのアカウントから開くと正しく動きませんでした。汎用の形に直してから使います。

function toGenericWebAppUrl_(url) {
  return String(url || '')
    .replace(/\/a\/macros\/[^\/]+\//, '/macros/')
    .replace(/\/a\/[^\/]+\/macros\//, '/macros/');
}

function appUrl_() {
  try {
    return toGenericWebAppUrl_(ScriptApp.getService().getUrl());
  } catch (err) {
    return '';
  }
}

ついでに、GASの画面はiframeの中で表示されるので、href="?page=links" のような相対リンクは使えません。押すと画面が真っ白になり、エラーも出ません。リンクは appUrl_() で作った絶対URLに target="_top" を付けます(別のページに埋め込んで表示する場合は target="_parent")。

手順5: プログラム用のデプロイを分けるときの注意

わたしのWebアプリは、同じプロジェクトに2本のデプロイがあります。

  • 閲覧用: 人がブックマークして開く
  • 自動更新用: 手元のPCから2時間おきにデータをPOSTする

最初は自動更新用だけを ANYONE_ANONYMOUS(匿名で誰でも)にしていました。その後、自動更新用のために appsscript.json を ANYONE_ANONYMOUS / USER_DEPLOYING へ書き換えてデプロイしたところ、未認証のPOSTもトークン付きのPOSTも 401 になりました(ストレージ読み取りで UNAUTHENTICATED)。原因の切り分けはしていません。

いまは2本とも同じ USER_ACCESSING / ANYONE のまま更新し、送信側のPCがGoogleアカウントのアクセストークンを付けて送っています。

このとき分かったことを並べます。

  • appsscript.json の webapp.access を書き換えて clasp push すると、その後の clasp deploy -i <デプロイID> は、書き換え後の値でそのデプロイを上書きする。pushの前に access の値を確認する
  • URLを変えずに更新するには、毎回 clasp deploy -i <同じデプロイID> を使う
  • 壊したら、直前の版を指定して同じデプロイIDへデプロイし直せば戻る
  • 新しい版は、デプロイ直後の数十秒ほど古い版のまま応答することがある。1回の失敗で判断しない

まとめ

  • 最初に、公開中のデプロイの実行者・利用者の承認・メールアドレスの取得可否を確認する。ドメインだけで決めない
  • URLキーは共有鍵による制限で、本人確認ではない。人ごとの利用許可には、本人確認と登録照合を分ける
  • getService().getUrl() は正規化してから使う。リンクは絶対URLにする
  • webapp.access を変えるときは、どのデプロイを上書きするかを先に確認する

その後: 管理画面とAPIで実行ユーザーを分けた

手順3で書いた共通の認証の仕組みは、その後、管理画面をAPI本体とは別のGASプロジェクトへ分けました。画面は USER_ACCESSING、DBを操作するAPIは USER_DEPLOYING で動かし、本人確認は画面側が渡すOAuthアクセストークンをAPI側で検証する形です。わたしの構成での対応記録であり、別ドメインでは getActiveUser() が常に使えないという意味ではありません。

GASのUSER_DEPLOYINGとUSER_ACCESSINGを分けた|管理画面に管理者が入れない

関連記事

AIエージェントの運用記録はnoteにまとめています

こうしたWebアプリやスクリプトを、AIにどう作らせて、どこで失敗したかは、noteマガジン「AIエージェント運用ノート」に記録しています。

注記

この記事の内容は、わたしが運用しているツールの技術検証を一般化したものです。コード例は説明用に書き直した最小例で、実際のIDや設定値は含んでいません。

コメント

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