Torihaji's Growth Diary

Little by little, no hurry.

Ruby で access_tokenも含めて、Google Drive APIとSheet APIを触り尽くす - 2章

はじめに

前回の続きです

torihazi.hateblo.jp

追記: 結論、どうしたらrefreshされるのかは分かりませんが、どうしてdrive.authorizationに指定する必要があるのかは分かりました。

前回までの結論をおさらいすると

  • list_filesがやっているのはクエリパラメータを添えて、httpリクエストを投げているだけ
  • drive.authorizationに設定できるのは文字列か apply!(Hash)というメソッドを持ったObject

本章でauthorizationのapply!の方を詳しく突き詰めることができると思う

authorizationについて

確か途中でSignetかapplyメソッドを持つものかとかあって、結局文字列指定すれば良いの?

と今思ったのでもう一回頭から整理し直す。

最初

drive = Google::Apis::DriveV3::DriveService.new
drive.authorization = ... # See Googleauth or Signet libraries

ここで定義。

drive.authorizationの正体

drive.authorization=から

BaseServiceのこれ
        # @!attribute [rw] authorization
        # @return [Signet::OAuth2::Client]
        #  OAuth2 credentials
        def authorization=(authorization)
          request_options.authorization = authorization
        end
BaseServiceのrequest_optionsはこれ。
self.request_options = Google::Apis::RequestOptions.default.dup
RequestOptionsはこれ
 # @!attribute [rw] authorization
      #   @return [Signet::OAuth2::Client, #apply(Hash)] OAuth2 credentials.
入れたrequest_optionsを list_filesとか具体のコマンド叩くときに
ApiCommand < HttpCommand作るときに指定
command.options = request_options.merge(options)

これでcommandのoptionsにhashではいる。 <= ここの理解が違った。

オープンクラスとかいうやつ

いい質問です!確認してみましょう。

コードを見ると:

# 最初にStructで定義
RequestOptions = Struct.new(
  :authorization,
  :retries,
  # ...
)

# その後、同じ名前でクラス定義
class RequestOptions
  # @!attribute [rw] authorization
  # ...
  
  def self.default
    @options ||= RequestOptions.new
  end

  def merge(options)
    # ...
  end
end

これはクラスの再オープン(reopening) です!

Rubyでは、Struct.newで作ったクラスを後からclassキーワードで再オープンして、メソッドを追加できます。

つまり: 1. Struct.newで基本的な構造(属性とアクセサ)を持つRequestOptionsクラスが作られる 2. その後class RequestOptionsで同じクラスを再オープンして、mergeメソッドやself.defaultメソッドを追加している

結果として、RequestOptionsは: - Structの機能(ハッシュライクなアクセス、dupなど)を持ちつつ - カスタムメソッド(mergedefault)も持つ

というStructベースのクラスになっています。

dupする対象は、Structで作られた上にカスタムメソッドが追加されたクラスということですね!

command.optionsはこれ

        # Request options
        # @return [Google::Apis::RequestOptions]
        attr_accessor :options

hashではない。だからオブジェクトみたいに入れられる。

つまり Google::Apis::RequestOptions が入ると。 optionsにはauthoriozationがある

でこのcommandに設定した optionsに含まれる authorizationが

httpリクエストに送られる直前で設定される。

def apply_request_options(req_header)
          if options.authorization.respond_to?(:apply!)
            options.authorization.apply!(req_header)
          elsif options.authorization.is_a?(String)
            req_header['Authorization'] = sprintf('Bearer %s', options.authorization)
          end
          req_header.update(header)
        end

再まとめ

drive.authorization =

=> BaseServiceの authorization= (設定できるのはSignet::OAuth2::Client)

=> これがrequest_options.authorizationに入れてる

=> request_options側では Signet::OAuth2::Client, #apply(Hash) が可能

=> 具体のlist_files用の make_simple_command(:get, 'files', options)

=> command.options = request_options.merge(options) (optionsはlist_filesで指定しない限りnil)

=> optionsがnilならrequest_optionsをそのまま入れる。

=> これによってcommand.options.authorizationとかでアクセスできる。

=> これが リクエスト直前にapply_request_headerでreq_headerを更新

=> (apply_request_headerの戻り値を受け取ってないけどrubyのハッシュは参照の値渡しだから良いみたい)

=> 知らなかった。

=> これを元に @http_res = client.run_request(method, url.to_s, body, request_header)

OK、authorizationの情報はhttpリクエスト時のheaderに関わってくる。

次の段階。

度々出てきている Signet::OAuth2::Clientやapplyとかはなぜ必要なんだろう。

とにかくapply!メソッドがrequest_headerの書き換えに使われている事は理解した。

まずは Signet::OAuth2::Client

Signet::OAuth2::Client

OAuth認証に使うみたい。

github.com

Googleが作っているのかな?

なぜこれを渡す必要があるんだろう。

確かこれだったな。

# See Googleauth or Signet libraries

signet/lib/signet/oauth_2/client.rb at c6da0473d9c985e5b57035f9349b31ae839ea67c · googleapis/signet · GitHub

Signet::OAuth2::Clientを見てみたけど apply!メソッドがないっぽい。

claudeに聞いてみた。

おっしゃる通りです!Signet::OAuth2::Clientにはapply!メソッドが定義されていません。
実は、apply!メソッドは別のgemであるgoogleauthの中のGoogle::Authモジュールで定義されています。

Signet::OAuth2::Clientは低レベルのライブラリで、googleauthがそれをラップしてapply!メソッドなどの便利な機能を追加しているんです!

ほー。ということで信じてみよう。

次なるターゲット Googleauth gem

Code search results · GitHub

確かに検索してみるとSignetの文字がチラチラあるな。

でapply!メソッドはあるかというと。

Code search results · GitHub

あ、あるな。

でどれもhashが引数として渡されている。

試しに1つ覗いてみる

github.com

    module BaseClient
      AUTH_METADATA_KEY = :authorization

      # Updates a_hash updated with the authentication token
      def apply! a_hash, opts = {}
        # fetch the access token there is currently not one, or if the client
        # has expired
        fetch_access_token! opts if needs_access_token?
        token = send token_type
        a_hash[AUTH_METADATA_KEY] = "Bearer #{token}"
        logger&.debug do
          hash = Digest::SHA256.hexdigest token
          Google::Logging::Message.from message: "Sending auth token. (sha256:#{hash})"
        end

        a_hash[AUTH_METADATA_KEY]
      end

あー、hashの更新で、これもauthorizationヘッダについて Beaderトークンを設定しているな。

hashを渡して関数内で更新すれば、親元も更新される(rubyのハッシュは参照の値渡しなので)

どれ使えばいいの?

とにかくこのgoogleauthを使って作ったやつをnewして drive.authorizationに設定すれば良さそう

そのnewしたやつは apply!メソッドがあるやつで。

ただ?

どれを使えばいいの?

そしてそのどれがどのくらいあるの?

ただ結局access_tokenがあればそれで良い事はわかっている。

なので一番シンプルなやつからやってみよう。

それがREADMEに載っているやつだと思う。知らんけど。

あ、これか??めっちゃぽいな

github.com

The closest thing to a base credentials class is the BaseClient module. It includes functionality common to most credentials, such as applying authentication tokens to request headers, managing token expiration and refresh, handling logging, and providing updater procs for API clients. Many credentials classes inherit from Signet::OAuth2::Client (lib/googleauth/signet.rb) class which provides OAuth-based authentication. The Signet::OAuth2::Client includes the BaseClient functionality. Most credential types either inherit from Signet::OAuth2::Client or include the BaseClient module directly. Notably, Google::Auth::Credentials (lib/googleauth/credentials.rb) is not a base type or a credentials type per se. It is a wrapper for other credential classes that exposes common initialization functionality, such as creating credentials from environment variables, default paths, or application defaults. It is used and subclassed by Google's API client libraries.

どれもBaseclient moduleをincludeしていますよ、そして Signet::OAuth2::Client includes the BaseClientだそう。

元々のSignetのものをgoogleauth側で拡張しているってことなのかな?

github.com

module Signet
  # OAuth2 supports OAuth2 authentication.
  module OAuth2
    # Signet::OAuth2::Client creates an OAuth2 client
    #
    # This reopens Client to add #apply and #apply! methods which update a
    # hash with the fetched authentication token.
    class Client
      include Google::Auth::BaseClient

確かにしているわ。

理解。

でさっきのCredentials.mdにあった基本のものからやっていくか。

一番シンプルなのはこれっぽい。

Google::Auth::BearerTokenCredentials - lib/googleauth/bearer_token.rb
Includes Google::Auth::BaseClient module
Implements Bearer Token authentication
Bearer tokens are strings representing an authorization grant
Can be OAuth2 tokens, JWTs, ID tokens, or any token sent as a Bearer in an Authorization header
Used when the end-user is managing the token separately (e.g., with another service)
Token lifetime tracking and refresh are outside this class's scope
No JSON representation for this type of credentials

Google::Auth::BearerTokenCredentials

既に取得済みのトークン文字列(Bearer token)を使って認証するクラス。

特徴: - トークン文字列をそのまま使う(OAuth2トークン、JWT、IDトークンなど何でもOK) - トークンの管理(有効期限、更新など)はこのクラスの責任外 - ユーザーが外部で別途トークンを管理している場合に使用

使い方:

credentials = Google::Auth::BearerTokenCredentials.new("ya29.a0AfH6...")
service.authorization = credentials

シンプルに「すでにあるトークンをAPIリクエストに添付するだけ」のクラス。

とclaudeに教えてもらったので、中身を見てみる。

Google::Auth::BearerTokenCredentials、君に決めた

github.com

      def initialize options = {}
        raise ArgumentError, "Bearer token must be provided" if options[:token].nil? || options[:token].empty?
        @token = options[:token]
        @expires_at = case options[:expires_at]
                      when Time
                        options[:expires_at]
                      when Numeric
                        Time.at options[:expires_at]
                      end

        @universe_domain = options[:universe_domain] || "googleapis.com"
      end

ホイホイ。という事はまずaccess_tokenをomniauthで適当に取るか。

サンプルがあったので。

github.com

あーそっか、access_tokenどこに載せようか問題で、そのまま止まってたんだった。

sessionでいいかな。

(裏でガチャガチャやり中〜〜〜)

rails cでとりあえずやった。

drive = Google::Apis::Drive::DriveService.new
drive.authorization = Google::Auth::BearerTokenCredentials.new({token: access_token})

としてdrive.list_files(q: "hoge")としたら

403 Caught error PERMISSION_DENIED: Request had insufficient authentication scopes.

と出た。

scopeが足りてないですよと。

これって今のtokenに付与されているスコープって見れないのかな?

まぁomniauth側で定義しているのが emailとprofileだからそうだと思うんだけど。

require 'net/http'
require 'json'

access_token = "ya29.a0AfH6..."

uri = URI("https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=#{access_token}")
response = Net::HTTP.get(uri)
token_info = JSON.parse(response)

puts token_info["scope"]
# => "https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/calendar"

これでいけるっぽい。

やってみるか。

rails c

https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid

やっぱりそうだ。

で、今回google drive触るにはそれ専用のscopeつける必要があったはず。

console.cloud.google.com

ここからproject移動してスコープに何を選択してるか見てみる

何も選択してなさそう。

ということでdriveを追加する

https://www.googleapis.com/auth/drive

セキュリティ的に広すぎるかもだがやり方がわからない。教えてください。

a = drive.list_files
Sending HTTP get https://www.googleapis.com/drive/v3/files?
200

a.class
Google::Apis::DriveV3::FileList

できたー。

一区切り。

これってアクセストークンが切れた時の挙動って時間待つしかないのかな

偽のトークンセットすれば良いみたい。

あー401になった。ただretryしてるな。なぜだ。

まぁいっか。

で試したいこと2つ目。

authorizationにtokenの文字列直指定でもいけるのか問題。

見た感じはいけると思うが。

これまで読んできた感じ。

あ、いけたわ。そりゃそうだよね。

わーいできたー。

でも実用性はそんなない。

やっぱりAPI叩く直前に勝手にtokenリフレッシュやってくれた方が良い。

でもこれってどうするのが適切なんだろう。

大抵、アプリ側のログインで取ってきたtokenを使うことがほとんどだし、

という事はbefore_actionとかで毎回確認してからセットするみたいなことした方が良いのかな。

こっちで更新してその場しのぎしてもそれを親元のアプリの方に連携するのって

どうやるのかわからんし、多分めんどそう。というかできるのかな。

次はrefreshの機構を試す。

さっきのCredentials.mdの中に以下があった。

User Authentication
1. Google::Auth::UserRefreshCredentials < Signet::OAuth2::Client - lib/googleauth/user_refresh.rb
   * For user refresh token authentication (from 3-legged OAuth flow)
   * Authenticates on behalf of a user who has authorized the application
   * Handles token refresh when original access token expires
   * Typically obtained through web or installed application flow. The JSON form of this credential type has a "type" field with the value "authorized_user".
Google::Auth::UserAuthorizer (lib/googleauth/user_authorizer.rb) and Google::Auth::WebUserAuthorizer (lib/googleauth/web_user_authorizer.rb) are used to facilitate user authentication. The UserAuthorizer handles interactive 3-Legged-OAuth2 (3LO) user consent authorization for command-line applications. The WebUserAuthorizer is a variation of UserAuthorizer adapted for Rack-based web applications that manages OAuth state and provides callback handling.

こいつ。Handles token refresh when original access token expiresとある。

さて元をのぞいてみる。

      def initialize options = {}
        options ||= {}
        options[:token_credential_uri] ||= TOKEN_CRED_URI
        options[:authorization_uri] ||= AUTHORIZATION_URI
        @project_id = options[:project_id]
        @project_id ||= CredentialsLoader.load_gcloud_project_id
        @quota_project_id = options[:quota_project_id]
        super options
      end

このsuperってどこだ。誰の?

あー、元のSignetの方か。

      def initialize options = {}
        @authorization_uri    = nil
        @token_credential_uri = nil
        @client_id            = nil
        @client_secret        = nil
        @code                 = nil
        @expires_at           = nil
        @issued_at            = nil
        @issuer               = nil
        @password             = nil
        @principal            = nil
        @redirect_uri         = nil
        @scope                = nil
        @target_audience      = nil
        @state                = nil
        @username             = nil
        @access_type          = nil
        @granted_scopes       = nil
        update! options
      end

てかこっちには access_tokenとかrefresh_tokenとか指定しなくて良いんだ。

うそ、あるわ、

update!の次のupdate_token!とかいうのでやってる

という事でUserRefreshCredentialsを作る。

app(dev)* drive.authorization = Google::Auth::UserRefreshCredentials.new ({
app(dev)*     access_token: token,
app(dev)*     refresh_token: ref,
app(dev)*     client_id: ENV['GOOGLE_CLIENT_ID'],
app(dev)*     client_secret: ENV['GOOGLE_CLIENT_SECRET']
app(dev)> })

こんな。

おし、list_filesいけた。

これで試しにtokenを適当にしてもう一回チャレンジして成功すればrefreshしているということ。

やってみよう。

app(dev)* drive.authorization = Google::Auth::UserRefreshCredentials.new ({
app(dev)*     access_token: "hogehoge",
app(dev)*     refresh_token: ref,
app(dev)*     client_id: ENV['GOOGLE_CLIENT_ID'],
app(dev)*     client_secret: ENV['GOOGLE_CLIENT_SECRET']
app(dev)> })

ダメだな

Caught error Unauthorized (Google::Apis::AuthorizationError)

何でだ。指定しているものが足りないのか?

そもそもどうやってrefreshしているのか。

あと少しだ。

多分、retryの発火はここでやっているはず。HttpCommandのdo_retry

        def do_retry func, client
          begin
            Retriable.retriable tries: options.retries + 1,
                                max_elapsed_time: options.max_elapsed_time,
                                base_interval: options.base_interval,
                                max_interval: options.max_interval,
                                multiplier: options.multiplier,
                                on: RETRIABLE_ERRORS do |try|
              # This 2nd level retriable only catches auth errors, and supports 1 retry, which allows
              # auth to be re-attempted without having to retry all sorts of other failures like
              # NotFound, etc
              auth_tries = (try == 1 && authorization_refreshable? ? 2 : 1)
              Retriable.retriable tries: auth_tries,
                                  on: [Google::Apis::AuthorizationError, Signet::AuthorizationError, Signet::RemoteServerError, Signet::UnexpectedStatusError],
                                  on_retry: proc { |*| refresh_authorization } do
                send(func, client).tap do |result|
                  if block_given?
                    yield result, nil
                  end
                end
              end
            end
          rescue => e
            if block_given?
              yield nil, e
            else
              raise e
            end
          end
        end

いかにもみたいな感じ。

Retriable.retriable tries: auth_tries,
                                  on: [Google::Apis::AuthorizationError, Signet::AuthorizationError, Signet::RemoteServerError, Signet::UnexpectedStatusError],
                                  on_retry: proc { |*| refresh_authorization } do

多分このonの中の例外になったら on_retryするとかじゃないの?

refresh_authorizationはというと

        # Refresh the authorization authorization after a 401 error
        #
        # @private
        # @return [void]
        def refresh_authorization
          # Handled implicitly by auth lib, here in case need to override
          logger.debug('Retrying after authentication failure')
        end

overrideしているのかと思い、googleauth覗きに行ったけどない。

振り出しか?

あれかな、project_id指定しないといけないのか?やってみるか。

結果変わらず。

なんでだよ。

これは3章か

結論

drive.authorizationに指定できるのはこれまでに3つわかった

  • oauth2.0で取得した access_tokenをそのまま直指定
  • Google::Auth::BearerTokenCredentials.new({token: access_token})
  • Google::Auth::UserRefreshCredentials.new (これはrefreshのやり方は未定)

このauthorizationに設定できるのは apply!(hash)メソッドを持っており、

そのapply!メソッドが内部でもらったハッシュの[:authorization]に "Bearer #{token}"を入れる

てことをしてる。

次回は どうしたらrefreshできるか。それがわかれば あとは 枝葉。

list_filesとかの使い方は sourceコード見ればわかる。