DeviseとDoorkeeper環境でのWebView認証 – アプリ内WebViewでの認証情報の同期 –

樋口雅拓。アウモ株式会社所属のソフトウェアエンジニア。人生の課題の半分はサウナに入れば解決すると信じている。

背景と目的

aumoではRailsを利用しています。Webの認証にDevise、APIの認証にDoorkeeperを利用しています。そのため、ブラウザはDeviseのセッションを持ち、iOS/AndroidアプリはDoorkeeperトークンを持っています。アプリのWebViewから認証が必要なWebページを表示する課題ができたため、tokenとセッションを交換するendpointを実装することで解決しました。ここでは、その事例について説明します。

aumoとは?

アウモ株式会社では、toC向けのメディアサービスである「aumo」、toB向けのSaaSサービスである「aumoマイビジネス」の2つの事業を行なっています。

メディアサービスaumoのiOS及びAndroidアプリに「aumoポイント」機能を追加することとなりました。対象アプリは、以下のボタンからインストールできます。

aumoポイントとは?

aumoでは立ち寄った施設の写真などを投稿する機能があります。この機能をより多くのユーザーに使っていただくため、投稿推進キャンペーンを行なっています。投稿推進キャンペーンでは、賞品としてAmazonギフト券を送付しています。商品としてaumoポイントを付与して、aumoポイントを様々な賞品に交換可能とすることで、キャンペーンの魅力を高めようとしています。

aumoポイント機能は、iOS/Androidアプリに実装し、その後Webに展開する予定です。そこで、Webの画面を作り、WebViewで表示させることとしました。

マイページ右上にポイント機能への導線を付け、クリックするとWebViewが立ち上がりマイポイントに保有しているポイントが表示されます。下図の左がマイページ、右がマイポイントです。aumoポイント機能はリリース済みのため、アプリを利用することで機能を確認することができます。

マイポイント画面はWeb認証が必要で、Webは認証にRailsのモジュールであるdeviseを利用しています。しかし、アプリから利用するAPIの認証はRailsのモジュールであるDoorkeeperを利用しており、アプリはDoorkeeperの認証情報を保持しています。つまり、アプリはdeviseの認証情報を保持していません。そこで、Doorkeeperの認証情報を使ってdeviseの認証情報を取得しようと考えました。

この機能を実装するため、DeviseとDoorkeeperの調査を行いました。

Devise

「ログイン状態を保持」をチェックした場合remember_user_token、しない場合_session_idにtokenが格納されます。
deviseはwardenに依存しており、wardenはrackに依存しています。
remember_meフラグをセットしてdeviseで認証した場合、deviseがremember_user_tokenをCookieにセットします。
フラグがない場合、Rackのsessionを利用します。

【Cookieのサンプル】
* remember_meあり: remember_user_token=xxxxx; path=/; expires=Tue, 15 Mar 2022 02:55:55 GMT; HttpOnly
* remember_meなし: _session_id=xxxxx; path=/; expires=Tue, 15 Mar 2022 02:55:55 GMT; HttpOnly

Doorkeeper

設定ファイルを見ると、HTTP HeaderのBearerを受け取っているようです。

# config/doorkeeper.rb
access_token_methods :from_bearer_authorization

詳細設計

Doorkeeperで認証して、deviseのセッションを返し、認証画面へリダイレクトすれば要件を満たせそうです。リダイレクト先が限定されることが課題です。

NodescriptiontypeServerApp
1start WebViewAppポイントアイコンをクリックしたときWebViewを開始
2/3WebView認証App/Server/api/v2/users/session を作り、Doorkeeper(access_token)で認証してdevise sign_in(session cookieをset)する。remember_meフラグをセットする。WebView起動時に/api/v2/users/sessionにリクエストする。そのとき、NativeからのAPIアクセスと同様にHTTP Headerに”Authentication: Bearer access_token”を付与する。すると、認証Cookie(remember_user_token)がSetCookieされる。
4/5ポイント画面Apphttps://aumo.jp/users/:user_uuid/point に認証Cookie(remember_user_token)が付いたリクエストが飛び、本人のみが自分のポイントを見れるようになる。
6dismissApp画面上部のToolbar左の X ボタンでWebViewを終了してNativeに戻る

実装

前置きが長くなりましたが、ここからは実装の解説です。分かりやすくするため、説明に不要な部分は省略しています。

Android

「1. start WebView」の実装です。Androidの実装ですが、iOSも同様です。マイページのポイントアイコンをクリックしたら、WebViewを立ち上げて作成した認証エンドポイントにアクセスします。下図はマイページのスクリーンショットで、右上の黄色いPのアイコンがポイントアイコンです。

この画面は実装的にはユーザ詳細画面(UserDetailActivity)となっており、自分だけでなく他人のユーザ詳細画面も閲覧できます。

aumoポイントは自分だけ閲覧できる仕様なので、ポイントアイコン(binding.pointIcon)は自分のユーザ詳細画面(isMyPage == true)のときだけ表示(visibility = View.VISIBLE)するようにしています。

アイコンをクリックしたときの挙動(pointIcon.setOnClickListener)として、WebViewの起動(WebViewActivity.startActivity)を行なっています。

# UserDetailActivity.kt
       if (isMyPage) {
           binding.pointIcon.visibility = View.VISIBLE
           binding.pointIcon.setOnClickListener { v ->
           WebViewActivity.startActivity(binding.root.context, "https://aumo.jp/api/v2/users/session")
           }
       }

「2/3. WebView認証」のAndroid実装です。対象リクエストのときに、Authrization Headerを追加します。今回はonActivityCreatedに以下の実装を仕込みました。汎用性を持たせるなら、WebViewClientのonLoadResourceあたりに持たせた方が良さそうです。

# WebViewFragment.kt
val HEADER = object : HashMap<String, String>() {
    init {
        put(AUTHORIZATION, BEARER + ACCESS_TOKEN)
    }
}
 val target_url = AppConst.API_USERS_SESSION
if (TextUtils.equals(url, target_url)) {
    webViewMain.loadUrl(url, HEADER)
}

Server

「2/3. WebView認証」のサーバ側実装です。まず、doorkeeperの配下にコントローラを追加します。

# config/routes.rb
scope 'api' do
  scope 'v2' do
    use_doorkeeper do
      controllers(tokens: 'api/v2/users/tokens')
    end
  end
end
namespace :api, defaults: {format: 'json'} do
  namespace :v2 do
    namespace :users do
      resource :session, only: %i[show]
    end
  end
end

追加したコントローラの中で、deviseの認証を行います。コントローラの実装は、以下のようになります。

まず、before_actionでdoorkeeper_authorizeを行います。

deviseのドキュメントを読むと、ログイン済みでセッションを再生成する場合はbypass_sign_inを使うように書いてあります。Doorkeeperでログイン済みのため、bypass_sign_inを使ってdeviseのセッションを取得しています。

deviseのセッションはSet-Cookieされます。最後にredirect_toで対象画面へ飛ばします。

# controllers/api/v2/users/sessions_controller.rb
class Api::V2::Users::SessionsController < ApiV2Controller
  before_action -> { doorkeeper_authorize!(:read) }, only: %i[show]
  def show
    bypass_sign_in current_user
    redirect_to user_point_index_path(current_user.uuid)
  end
end

Android

「3. WebView認証」からの応答でWebViewにdeviseのセッションクッキーが設定されます。WebViewがリダイレクト先にセッションクッキー付きのリクエストを送ります。これにより、deviseで認証が必要な「4/5. ポイント画面」を表示できます。下図が「4/5. ポイント画面」です。

最後に「6. dismiss」として画面上部ツールバーの左に表示される「X」アイコンをクリックするとWebViewを閉じるようにします。

# WebViewFragment.kt
toolbar = builder.toolbar
toolbar.setNavigationOnClickListener { view -> this@WebViewActivity.finish() }

まとめ

Doorkeeperのaccess tokenを使って、WebViewでdevise認証が必要な画面を表示することができました。ただし、飛び先が固定化されてしまっており、拡張性に欠ける状態です。

よくあるケースだと思いますが、ネットで事例を見つけることができませんでした。同様の事例をご存知の方は、公開していただけると助かります。このblogが皆様の助けになれば幸いです。

aumoでは、一緒により良いプロダクトにしてくれるエンジニアやPdMなど多岐に渡って職種を募集しています!