Google Apps ScriptとOAuthライブラリで、トークンが無効だと言われて困る

これが出る↓

「トークンが無効か、有効期限が切れています。もう一度お試しください。」というエラーが表示されているスクリーンショット。
英語だと”The state token is invalid or has expired. Please try again.”らしいですよ。

解決策

usercallbackの代わりに、doGet()でアクセストークンの処理をしよう!
(アクセストークンという言葉のこの使い方、誤りな気がするが、ヨシ!)

YouTube API用なので、適宜読み替えてください。
それから、いろいろなとこからコピペしたキメラコードなので十分気をつけて実行してください

デプロイ設定
  • 次のユーザとして実行:自分(Googleアカウントのメールアドレスがここに入る)
  • アクセスできるユーザー:全員
ライブラリ等
  • OAuth2: バージョン43
  • YouTube(13FP5EWK7x2DASsiBXETcr0TQ07OCLEVWOoY1jbVR-bqVpFmsydUSXWdR): バージョン4
  • URI.js: リンク先でURI.jsのみを選択してBuild!を押して出てきたものをそのままGASのプロジェクトに貼り付けた
OAuth.gs: OAuthの処理を書いた部分
const CLIENT_ID = PropertiesService.getScriptProperties().getProperty('client_id');
const SECRET = PropertiesService.getScriptProperties().getProperty('client_secret');

// セットアップ/ログアウトするOAuthの設定に与える(与えた)一意の好きな文字列
// Webからアクセスする場合には使われないようにしてある
const SETUP_OATUH_USERNAME = 'some_username';

// OAuth2が返すリダイレクト先のURLを生成する関数を上書きする
// URLはデプロイした「ウェブアプリ」のもの
OAuth2.getRedirectUri = function getRedirectUri(optScriptId) {
  return 'https://script.google.com/macros/s/<deployed id here>/exec';
};

function setup(userID = SETUP_OATUH_USERNAME) {
  const service = getYouTubeService(userID);
  YouTube.setTokenService(() => service.getAccessToken());
  if (service.hasAccess()) {
    var result = YouTube.channelsList('snippet', { mine: true });
    Logger.log('-- ユーザーのYouTubeアカウントの情報 --');
    Logger.log(JSON.stringify(result, null, 2));
    Logger.log('すでにログインされています。');
    return 'Already Logged in.';
  } else {
    const authorizationUrl = new URI(service.getAuthorizationUrl());
    authorizationUrl.setSearch('state',`${userID}_callback_${authorizationUrl.search(true)['state']}`,);
    Logger.log('次のURLを開いて認証してください: %s', authorizationUrl.href());
    return authorizationUrl.href();
  }
}

function getYouTubeService(userID) {
  // Logger.log(`OAuth2 service will be created as YouTube_${userID}.`)
  return OAuth2.createService(`YouTube_${userID}`)
      // Google APIsを使うためのURLたち
      .setAuthorizationBaseUrl('https://accounts.google.com/o/oauth2/v2/auth')
      .setTokenUrl('https://oauth2.googleapis.com/token')

      // GCPから持ってきたIDとSecret
      .setClientId(CLIENT_ID)
      .setClientSecret(SECRET)

      // usercallbackにアクセスしたときに呼び出したい関数
      // 関数自体は今回はいらないのだが、設定しないとエラーになる
      .setCallbackFunction('authCallback')

      // トークンの保存先。getScriptPropertyのままだとこちらから他人のトークンが見放題になる
      //.setPropertyStore(PropertiesService.getUserProperties())
      .setPropertyStore(PropertiesService.getScriptProperties())

      // 必要なスコープ(カンマ区切り)
      .setScope('https://www.googleapis.com/auth/youtube')

      // --- 以下YouTube API用のパラメーター設定 ---
      // Google OAuth 2.0エンドポイントが認可コードを返すかどうかを決める
      // Web Server Applicationのときは「code」
      .setParam('response_type', 'code')

      // Offlineアクセスを有効にすることでRefresh Tokenが帰ってくるようになる
      .setParam('access_type', 'offline')

      // Refresh Tokenが欲しいのでOAuth同意画面を表示させる
      .setParam('prompt', 'consent')
}

// コールバックを処理する関数
// return HtmlService...は元々`/usercallback`にリダイレクトしていた時のやつ
// いらなければ消して
function authCallback(request, userID = SETUP_OATUH_USERNAME) {
  const youtubeService = getYouTubeService(userID);
  const isAuthorized = youtubeService.handleCallback(request);
  if (isAuthorized) {
    return '認証されました!';
    //return HtmlService.createHtmlOutput('認証されました!');
  } else {
    return '拒否されました!';
    //return HtmlService.createHtmlOutput('拒否されました!');
  }
}

function logout() {
  var service = getYouTubeService(SETUP_OATUH_USERNAME);
  service.reset();
}
doGet.gs: デプロイしたURLを開いたときに動作する関数
function doGet(e) {
  /* ここから「Anonymous」用の設定 */
  const page = e.parameter.state
  if (page == "auth" || !(page) ) {
  	// URLのstateパラメタがauthか、何もなければここにくる
  	// そしてauth.htmlを表示する
  	let html = HtmlService.createTemplateFromFile("auth").evaluate();
  	html.setTitle("OAuth認証リンク生成ページ");
  	//html.setFaviconUrl("");
  	return html;
  } else if (/^.+_callback_.+$/i.test(e.parameter.state)) {
  	// URLのstateパラメタがuserID_callback_stateの形にここにくる
    let data = e;
    const [_, userID, state] = e.parameter.state.match(/^(.+)_callback_(.+)$/i);
    ['parameter', 'parameters'].forEach(key => data[key].state = state);
    ['parameter', 'parameters'].forEach(key => data[key].serviceName = `YouTube_${userID}`);
    let queryParameters = new URI("?" + data.queryString); 
    queryParameters.setSearch("state", state);
    data.queryString = queryParameters.query();

    let htmlContents = authCallback(data, userID);
    let html = HtmlService.createHtmlOutput(htmlContents);
    html.setTitle("認証画面")

    return html;
    
  } else {
    return HtmlService.createHtmlOutput("存在しないStateパラメータが指定されています。");
  }
}
auth.html: 認証URLを吐き出すページ
<!DOCTYPE html>
<html>
  <head>
    <base target="_top" />
  </head>
  <body>
    <p>
      <label>ユーザ名 <input type="text" name="name" id="userID" /></label>
    </p>
    <p><button onClick="genBtnClick()">送信</button></p>
    <p><a id="link" href="#"></a></p>
  </body>
  <script>
    //from: https://hazime-style.com/?p=1473
    function genBtnClick() {
      // 実行してよいか確認する
      const checkGenFlg = window.confirm("登録用URLを生成します。");

      if (checkGenFlg) {
        // フォームの入力値を取得する
        const userID = document.getElementById("userID").value;
        google.script.run
          .withSuccessHandler(genSuccess)
          .withFailureHandler(genFail)
          .setup(userID);
      } else {
        // nothing to do.
      }
    }

    function isValidUrl(string) {
      try {
        new URL(string);
        return true;
      } catch (err) {
        return false;
      }
    }

    function genSuccess(result) {
      let link = document.getElementById("link");

      // リンクが生成されなかった (既にログイン済みの場合とか) なら帰ってきた値をそのまま表示する
      if (!isValidUrl(result)) {
        link.textContent = String(result);
        return;
      }
      let url = result;
      link.setAttribute("href", url);
      link.textContent = "登録リンク";
    }

    function genFail() {
      // アラートを表示する
      alert("失敗しました。");
    }
  </script>
</html>

これらのコードを全部用意した上でデプロイし、リンクを開いて名前を入力→送信ボタンでリンクが生成されるはずです。
そのリンクでログインすれば、指定した場所にトークンが保存されます。
一応、OAuth.gsでSETUP_OATUH_USERNAMEを設定した上でLogin()を実行して得られたリンクを誰かに渡してもいけるはず。

ちなみに、デプロイした後でGCPでリダイレクト先をhttps://script.google.com/macros/s/<deployed id here>/execに設定しておいてください。

苦労したから何がダメだったのか聞いてくれよ

「トークンが無効か、有効期限が切れています。もう一度お試しください。」というエラーは英語で“The state token is invalid or has expired. Please try again.”で、state tokenなるものが上手くいってないようである。

そのstateトークンは/usercallbackにリダイレクトしてきたときに使われるもので、事前に生成したstateトークンにマッチする関数を実行してくれるらしい。
ところがこのstateトークンは毎回違うし、もちろんユーザーごとの管理になっている。

だから「全てのユーザにアクセス権があるが実行者は自分」という設定だと、自分のアカウントで生成されたstateトークンを他人が使うことになり存在しない関数を参照してしまう、という感じだと思われる(よくわからん)

そこでGETリクエストでログイン後にもらえるトークンをもらって、doGet()でコールバックの処理を行うことでstateトークンが要らない子になり上手く認証できるようになった……んだと思ってる。

それにしても、これの解決にだいぶ時間がかかったよ……。ちゃんと理解していればすぐなんだと思うと勉強するべきだと思えてくる。

コメント

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