【devise-token-auth x OmniAuth2.0 x SPA】でログイン機能を実装

devise-token-authを使用して以下のようなログイン機能を実装する方法についてのまとめ。

1. ログイン用ボタンを押下
1. 新規タブでプロバイダーの認証画面に遷移
1. 認証が完了した後、コールバックにてユーザー登録
1. コールバックのウィンドウを閉じて、フロント側に認証情報を渡す

## gem
- [devise](https://github.com/heartcombo/devise):
Railsで認証を実装するためのgem
- [omniauth](https://github.com/omniauth/omniauth):　マルチプロバイダー認証を行うためのgem
- [omniauth-rails-csrf-protection](https://github.com/cookpad/omniauth-rails_csrf_protection): [CVE-2015-9284](https://nvd.nist.gov/vuln/detail/CVE-2015-9284)の脆弱性に対処するためのgem。OmniAuth2.0からはCSRF対策のためプロバイダーの認証画面にはPOSTメソッドを使用してアクセスする。
- [devise-token-auth](https://github.com/lynndylanhurley/devise_token_auth):
deviseとomniauthを使用してトークンによる認証を実装するためのgem

## API側の実装
### インストールと修正
```ruby:Gemfile
gem 'devise_token_auth', git: 'https://github.com/lynndylanhurley/devise_token_auth.git'

gem 'omniauth-rails_csrf_protection'
```

devise_token_authにdevie及びomniauthが包含されているので、追記する必要はない。また、CSRF対策のためプロバイダーの認証にはPOSTメソッドでアクセスしたいので、[23d6b81b1](https://github.com/lynndylanhurley/devise_token_auth/commit/23d6b81b14fe39b5e4ce2b0dde897e4abcd850e8)を適用するためにgithubから取得するようにしている('22/2/22)。

`bundle install`をしたら

```
$ rails g devise_token_auth:install User auth
```
をしてdevise-token-auth用のモデルを生成する。[このコマンドで以下の変更が加えられる](https://devise-token-auth.gitbook.io/devise-token-auth/config)。

> * An initializer will be created at `config/initializers/devise_token_auth.rb`. [Read more](initialization.md).
> * A model will be created in the `app/models` directory. If the model already exists, a concern (and fields for `Mongoid`) will be included at the file. [Read more](/docs/usage/model_concerns.md).
> * Routes will be appended to file at `config/routes.rb`. [Read more](/docs/usage/routes.md).
 >* A concern will be included by your application controller at `app/controllers/application_controller.rb`. [Read more](/docs/usage/controller_methods.md).
> * For `ActiveRecord` a migration file will be created in the `db/migrate` directory. Inspect the migrations file, add additional columns if necessary, and then run the migration:
> ~~~
> rake db:migrate
> ~~~

apiとして実装するため`config/routes.rb`を以下に修正してdevise-token-authのコントローラーをネームスペースに収容する。

```ruby:config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      mount_devise_token_auth_for(
        'User',
        at: 'auth'
      )
    end
  end
end

```

今回はomniauthによる認証のみを実装するので、deviseの`omniauthable`モージュルのみを使用するため、生成されたマイグレーションファイル及びモデルの内容を以下のように修正する。

```ruby:db/migrate/*_devise_token_auth_create_users.rb
class DeviseTokenAuthCreateUsers < ActiveRecord::Migration[6.1]
  def change
    create_table(:users) do |t|
      ## Required
      t.string :provider, null: false
      t.string :uid, null: false

      ## Database authenticatable
      # t.string :encrypted_password, :null => false, :default => ""

      ## Recoverable
      # t.string   :reset_password_token
      # t.datetime :reset_password_sent_at
      # t.boolean  :allow_password_change, :default => false

      ## Rememberable
      # t.datetime :remember_created_at

      ## Confirmable
      # t.string :confirmation_token
      # t.datetime :confirmed_at
      # t.datetime :confirmation_sent_at
      # t.string :unconfirmed_email # Only if using reconfirmable

      ## Lockable
      # t.integer  :failed_attempts, :default => 0, :null => false # Only if lock strategy is :failed_attempts
      # t.string   :unlock_token # Only if unlock strategy is :email or :both
      # t.datetime :locked_at

      ## User Info
      t.string :name
      t.string :nickname
      t.string :image
      t.string :email
      t.text :description

      ## Tokens
      t.text :tokens

      t.timestamps
    end

    # add_index :users, :email,                unique: true
    add_index :users, %i[uid provider], unique: true
    # add_index :users, :reset_password_token, unique: true
    # add_index :users, :confirmation_token,   unique: true
    # add_index :users, :unlock_token,         unique: true
  end
end
```

モデルには登録の際に[Auth Hash Schema](https://devise-token-auth.gitbook.io/devise-token-auth/config)に従ったハッシュが渡され、[`assing_provider_attrs`](https://github.com/lynndylanhurley/devise_token_auth/blob/23d6b81b14fe39b5e4ce2b0dde897e4abcd850e8/app/controllers/devise_token_auth/omniauth_callbacks_controller.rb#L114)メソッドによって、モデルがinfo内の属性を持っていれば代入される。deviseとOmniAuthを使用した登録については以下を参照。

https://github.com/heartcombo/devise/wiki/OmniAuth:-Overview

deviseを別途インストールしていないので`user.rb`に`extend Devise::Models`を`devise`の前に追記。

```ruby:app/models/user.rb
class User < ActiveRecord::Base
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  extend Devise::Models
  devise :omniauthable 
  include DeviseTokenAuth::Concerns::User
end
```

またUserモデルに`encrypted_password`をもたせていないので、`omniauth_collbacks_controller.rb`及び`config/routes.rb`を以下のように上書き。

```app/controllers/api/v1/auth/omniauth_collbacks_controller.rb
class Api::V1::Auth::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCallbacksController
  protected

  def handle_new_resource
    @oauth_registration = true
    # don't set password
    # set_random_password
  end
end
```

```ruby:config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      mount_devise_token_auth_for(
        'User',
        at: 'auth',
        controllers: {
          omniauth_callbacks: 'api/v1/auth/omniauth_callbacks',
        },
      )
    end
  end
end
```

最後にRailsのCSRF機能オフにするため以下の設定を追記。

```config/application.rb
module AppName
  class Application < Rails::Application
    # Don't use csrf tokens
    config.action_controller.allow_forgery_protection = false
  end
end
```

### プロバイダーの登録
以下を参照。コールバック用のパス(デフォルトの場合`/omniauth`)と実際にリクエストするパス(`/auth`)が異なるので注意。

https://devise-token-auth.gitbook.io/devise-token-auth/config/omniauth

ここまでで`/api/v1/auth/:provider`にPOSTメソッドでアクセスすれば認証ページにリダイレクト、コールバックによる登録が可能になる。

## SPA側の実装
次にSPA側の実装を行う。POSTメソッドでプロバイダー認証画面を新規タブで開くための処理は以下のようになる。`authURL(provider)`は`{{APIのベースURL}}/api/v1/auth/:provider`を返す関数。

```tsx
type Provider = 'github'
```

```tsx
const openAuth = (provider: Provider): Window | null => {
  // open with post method to protect from csrf
  let blankForm = document.createElement('form')
  blankForm.target = provider
  blankForm.method = 'post'
  blankForm.action = `${authURL(provider)}?omniauth_window_type=newWindow`

  // connect form
  blankForm.style.display = 'none'
  document.body.appendChild(blankForm)

  let authWindow = window.open('', provider)
  blankForm.submit()

  // cut form
  document.body.removeChild(blankForm)

  return authWindow
}
```

新規タブで開くウィンドウ名(`window.open`の第2引数)を`form.target`の値にすることで、一時的に`about:blank`で開かれたタブに`form.submit()`でPOSTメソッドを送信している。`window.open`の詳細は以下。

https://developer.mozilla.org/ja/docs/Web/API/Window/open

認証用URLに`omniauth_window_type=newWindow`を渡すことで、コールバックの際に[外部ウィンドウ用のページ](https://github.com/lynndylanhurley/devise_token_auth/blob/master/app/views/devise_token_auth/omniauth_external_window.html.erb)がdevise-token-authによって返される。

```html:omniauth_external_window.html.erb
<!DOCTYPE html>
<html>
  <head>
    <script>
      /*
        The data is accessible in two ways:

        1. Using the postMessage api, this window will respond to a
            'message' event with a post of all the data. (This can
            be used by browsers other than IE if this window was
            opened with window.open())
        2. This window has a function called requestCredentials which,
            when called, will return the data. (This can be
            used if this window was opened in an inAppBrowser using
            Cordova / PhoneGap)
      */

      var data = JSON.parse(decodeURIComponent('<%= ERB::Util.url_encode( @data.to_json ) %>'));

      window.addEventListener("message", function(ev) {
        if (ev.data === "requestCredentials") {
          ev.source.postMessage(data, '*');
          window.close();
        }
      });
      function requestCredentials() {
        return data;
      }
      setTimeout(function() {
        document.getElementById('text').innerHTML = (data && data.error) || 'Redirecting...';
      }, 1000);
    </script>
  </head>
  <body>
    <pre id="text">
    </pre>
  </body>
</html>
```

このページにコメントとして記載されている1の方法で、`data`にアクセスする。`data`には[`@auth_params`](https://github.com/lynndylanhurley/devise_token_auth/blob/23d6b81b14fe39b5e4ce2b0dde897e4abcd850e8/app/controllers/devise_token_auth/omniauth_callbacks_controller.rb#L189)及びuserのハッシュが展開されたオブジェクトが代入されている。

このウィンドウに定期的に`postMessage`を行い、コールバックページから返されたメッセージの`e.data`に認証用の情報が含まれていれば情報をクライアント側に保存し、インターバルを抜ける。Reactの場合は以下のようなフックを使用する。`postMessage`の詳細は以下。

https://developer.mozilla.org/ja/docs/Web/API/Window/postMessage


```tsx
  const [authProvider, setAuthProvider] = useState<Provider | null>(null)
  const [openAuthWindow, setOpenAuthWindow] = useState<Window | null>(null)
  const { setCurrentUser } = useAuth()

  useEffect(() => {
    if (!openAuthWindow) return
    // See https://github.com/lynndylanhurley/devise_token_auth/blob/master/app/views/devise_token_auth/omniauth_external_window.html.erb
    window.addEventListener('message', (e) => {
      const data = camelcaseKeys(e.data) as { [key: string]: any }
      // See https://github.com/lynndylanhurley/devise_token_auth/blob/23d6b81b14fe39b5e4ce2b0dde897e4abcd850e8/app/controllers/devise_token_auth/omniauth_callbacks_controller.rb#L189
      const authParamKeys = ['authToken', 'clientId', 'uid']
      if (
        !authParamKeys.reduce(
          (acc: boolean, cur: string): boolean =>
            acc && Object.keys(data).includes(cur),
          true
        )
      ) {
        return
      }

      setAuthHeaders({
        accessToken: data.authToken,
        client: data.clientId,
        uid: data.uid,
      })
      setCurrentUser(data as User)

      clearInterval(timer)
      setAuthProvider(null)
      setOpenAuthWindow(null)
    })

    const timer = setInterval(() => {
      openAuthWindow.postMessage('requestCredentials', domainURL)
    }, 200)
  }, [openAuthWindow])
```

devise-token-authについての詳細は以下及び[ソースコード](https://github.com/lynndylanhurley/devise_token_auth/blob/23d6b81b14fe39b5e4ce2b0dde897e4abcd850e8/app/controllers/devise_token_auth/omniauth_callbacks_controller.rb)を参照。

https://devise-token-auth.gitbook.io/devise-token-auth/