GAS『OAuth2 Authorization』の原因と対処法

GAS『OAuth2 Authorization』の原因と対処法

Google Apps Scriptから外部API/Google APIにOAuth2で接続するとき、認可フローやクライアント設定、リダイレクトURI、スコープ、トークン保存の不整合で失敗する。ここでは発生条件の整理、最短復旧の手順、実用テンプレコード、典型的なエラーごとの修正ポイントをまとめる。

エラーメッセージと起きる場面

「Authorization Error」「redirect_uri_mismatch」「invalid_client」「invalid_grant」「access_denied」「insufficient_scope」「unauthorized_client」などが代表。WebアプリのdoGet/doPost、HTMLサービスからの開始、UrlFetchAppでのAPI呼び出し時、トリガー実行時に発生しやすい。

発生条件の早見表

クライアントID/シークレットの誤りや環境(本番・テスト)取り違え。承認済みのリダイレクトURIにApps ScriptのコールバックURLが未登録。スコープ不足または過剰。ユーザー/組織側で同意が未完了。リフレッシュトークン未取得(offlineアクセス未指定)や失効。GCPプロジェクト不整合(別プロジェクトでデプロイ)。実行主体(オーナー/アクセスユーザー)の食い違い。

最短復旧フロー(上から順に確認)

クライアントID・シークレットが正しいか確認。承認済みリダイレクトURIに「Apps Scriptのコールバック」を追加。スコープは最小限で列挙。オーナーがまず認可を通す。リフレッシュトークン取得のためaccess_type=offlineとprompt=consentを付与。トークンはUserPropertiesなどに保存。401/invalid_grant時は再同意へフォールバック。

OAuth2ライブラリの導入と基本サービス定義

// 必要:Apps Scriptの「ライブラリ」で OAuth2 を追加済みであること
// 外部サービス例(authorization_code)
function getService() {
  return OAuth2.createService('example') // サービス名(ユーザーごとのトークンを区別)
    .setAuthorizationBaseUrl('https://provider.example.com/oauth/authorize')
    .setTokenUrl('https://provider.example.com/oauth/token')
    .setClientId(PropertiesService.getScriptProperties().getProperty('CLIENT_ID'))
    .setClientSecret(PropertiesService.getScriptProperties().getProperty('CLIENT_SECRET'))
    .setCallbackFunction('authCallback') // コールバックは後述
    .setPropertyStore(PropertiesService.getUserProperties()) // ユーザー単位で保存
    .setScope('read write') // プロバイダ仕様に合わせる(スペース区切りやカンマ区切り)
    .setParam('access_type', 'offline')   // Google系で長期トークンが必要なとき
    .setParam('prompt', 'consent');       // 毎回同意画面を出してrefresh_token取得
}

リダイレクトURIとコールバック関数を合わせる

// 1) コールバック(固定名でOK、サービス名は自動で付与される)
function authCallback(request) {
  const service = getService();
  const isOk = service.handleCallback(request);
  return HtmlService.createHtmlOutput(isOk ? 'Success' : 'Denied');
}

// 2) 承認開始(WebアプリURLにアクセス → 認可画面へ)
function doGet(e) {
  const service = getService();
  if (!service.hasAccess()) {
    const url = service.getAuthorizationUrl();
    return HtmlService.createHtmlOutput('<a href="' + url + '">Authorize</a>');
  }
  return HtmlService.createHtmlOutput('Already authorized');
}

// ※ プロバイダのダッシュボードに登録するリダイレクトURI:
//   https://script.google.com/macros/d/{SCRIPT_ID}/usercallback
// (OAuth2ライブラリの既定コールバックに合わせる)

トークン保存・更新・ヘッダー付与の実装

// 認可済みかを確認し、APIを呼ぶ
function callApi(endpoint) {
  const service = getService();
  if (!service.hasAccess()) throw new Error('Not authorized');

  const headers = { Authorization: 'Bearer ' + service.getAccessToken() };
  const res = UrlFetchApp.fetch(endpoint, { headers, muteHttpExceptions: true });
  const code = res.getResponseCode();

  if (code === 401) {
    // 期限切れや撤回。再同意へ誘導(例:URLをログに出す)
    service.reset(); // 既存トークンを破棄
    console.log('Re-auth URL:', service.getAuthorizationUrl());
    throw new Error('Unauthorized. Please re-authorize.');
  }
  return res.getContentText();
}

Google APIをOAuth2(HTTP)で呼ぶ最小例

// 例:Google Drive API v3 をHTTPで直接呼ぶ場合
function getDriveFiles() {
  const service = OAuth2.createService('gdrive')
    .setAuthorizationBaseUrl('https://accounts.google.com/o/oauth2/v2/auth')
    .setTokenUrl('https://oauth2.googleapis.com/token')
    .setClientId(PropertiesService.getScriptProperties().getProperty('G_CLIENT_ID'))
    .setClientSecret(PropertiesService.getScriptProperties().getProperty('G_CLIENT_SECRET'))
    .setCallbackFunction('authCallback')
    .setPropertyStore(PropertiesService.getUserProperties())
    .setScope('https://www.googleapis.com/auth/drive.metadata.readonly')
    .setParam('access_type', 'offline')
    .setParam('prompt', 'consent');

  if (!service.hasAccess()) {
    throw new Error('Authorize: ' + service.getAuthorizationUrl());
  }
  const res = UrlFetchApp.fetch(
    'https://www.googleapis.com/drive/v3/files?pageSize=10',
    { headers: { Authorization: 'Bearer ' + service.getAccessToken() } }
  );
  return JSON.parse(res.getContentText());
}

invalid_grant/redirect_uri_mismatchの修正ポイント

invalid_grantは、リフレッシュトークン未発行、同意撤回、クライアント切り替え、時間ずれで出やすい。access_type=offlineとprompt=consentを付けて再同意し、古いトークンを破棄。redirect_uri_mismatchは、プロバイダ側の承認済みリダイレクトURIに「Apps Scriptのusercallback」を正確に登録して一致させる。

スコープ設計と最小権限の原則

必要なスコープだけを列挙。Google APIはURL形式のフルスコープを用いる。複数スコープはスペース区切り。不要なスコープは削除し、再同意を促してリスクと審査負荷を下げる。

GCPプロジェクトの整合・検証ステータス

クライアントIDを発行したGCPプロジェクトと、スクリプトが紐づくプロジェクトを混同しない。OAuth同意画面(テスト/本番)に応じてユーザー制限が変わる。社内利用はドメイン内ユーザーに限定。本番公開やセンシティブスコープは検証要件に注意。

複数ユーザー運用(Webアプリ/トリガーと実行主体)

ライブラリのPropertyStoreをUserPropertiesにしてユーザーごとにトークンを分離。ウェブアプリの「実行するユーザー」を設計に合わせる。トリガーで外部APIを叩く場合、トリガー作成者のトークンが必要になるため、作成者で認可を通す。

よくある失敗→修正(クイック表)

redirect_uri_mismatchはコールバックURLのタイプミス。invalid_clientはクライアントID/シークレットの環境違い。access_deniedはユーザーが同意を拒否。insufficient_scopeはスコープ不足で再同意が必要。invalid_grantはトークン撤回・期限切れ・再認可要。

動作確認テンプレ(権限とAPI応答をログ)

function diagAuth() {
  const svc = getService();
  if (!svc.hasAccess()) {
    console.log('Authorize:', svc.getAuthorizationUrl());
    return;
  }
  console.log('Access token (short):', (svc.getAccessToken() || '').slice(0,16));
  try {
    const text = callApi('https://httpbin.org/bearer');
    console.log('OK:', text.slice(0,120));
  } catch (e) {
    console.error('API fail:', e.message);
  }
}

再発防止チェックリスト

クライアントID/シークレットは環境ごとに分ける。承認済みリダイレクトURIにusercallbackを登録。スコープは最小限で固定し、変更時は再同意。トークンはUserPropertiesに保存し、401発生時はreset→再同意。Webアプリ/トリガーの実行主体を把握し、必要なユーザーで認可を通す。GCPプロジェクトとデプロイ環境の整合を維持する。