Rails + Devise + OmniAuthにて、Deviseで認証を、OmniAuthでOAuth2.0の認可コードグラントフローの認可だけを扱えるか試してみた

RubyOmniAuth gemの用途としてREADMEには

OmniAuth is a library that standardizes multi-provider authentication for web applications. It was created to be powerful, flexible, and do as little as possible. Any developer can create strategies for OmniAuth that can authenticate users via disparate systems. OmniAuth strategies have been created for everything from Facebook to LDAP.

https://github.com/omniauth/omniauth

と書かれています。

 
とはいえ、READMEにあるような認証用途だけではなく、OAuth2.0の認可コードグラントフローをかんたんに扱うためのgemとしても使えそうに感じました。

そこで、「Rails + Devise + OmniAuthにて、Deviseで認証を、OmniAuthでOAuth2.0の認可コードグラントフローの認可だけを扱えるか」試してみたくなったため、調べてみたときのメモを残します。

 
目次

 

今回のシナリオ

OAuth2.0の クライアント (以降、Client)と 認可サーバ兼リソースサーバ (以降、AS兼RS)の計2つのRailsアプリを作成します。

作成した2つのRailsアプリを使って、以下のような流れが実現できるかを確認します。

  1. ClientがDeviseを使い、ユーザーがemailを使ってClientアプリにログイン
  2. ClientがOmniAuthを使い、OAuth2.0の認可コードグラントフローを開始
  3. AS兼RSがDeviseを使い、ユーザーがemailを使ってAS兼RSアプリにログイン
  4. AS兼RSがDoorkeeperを使い、OAuth2.0の認可コードグラントフローに従ってClientへアクセストークンを返却
  5. ClientがFaraday + アクセストークンを使い、AS兼RSのデータ取得用APIからデータを取得

また、「もし5.の時点でアクセストークンの有効期限切れの場合は、アクセストークンを更新してからデータを取得する」みたいなことも試してみます。

 

やらないこと

  • 本番運用で耐えられるような、細かなバリデーションやユースケース
    • 「リフレッシュトークンまわりの挙動」など、今回検証したいことから外れていることは深堀りしない
    • そもそも調査用のコードを入れてるので、そのまま本番運用に持っていくことは想定していない
  • Deviseでのログイン以外の機能を使うこと
    • パスワードリセットやメールアドレス変更は実装しない
  • Clientでアクセストークンの有効期限の事前チェック
    • 今回は、AS兼RSにアクセストークンを投げることで、有効/無効を確認している

 

環境

  • AS兼RSアプリ
    • rails 7.0.2.3
    • devise 4.8.1
    • doorkeeper 5.5.4
  • Clientアプリ
    • rails 7.0.2.3
    • devise 4.8.1
    • omniauth 2.0.4
    • omniauth-oauth2 1.7.2
    • omniauth-rails_csrf_protection 1.0.1
    • oauth2 1.4.9
    • faraday 2.2.0

 

AS兼RSアプリの実装

今回は、以前作ったOIDCのOPと同じようなDevise + Doorkeeperの設定とします。
Railsとdoorkeeper-openid_connectやOmniAuth を使って、OpenID Connectの OpenID Provider と Relying Party を作ってみた - メモ的な思考的な

 

初期セットアップ

rails new します。

% bundle exec rails new oauth_as_rs --minimal 

 
必要なgemをGemfileに追加します。

gem 'devise'
gem 'doorkeeper'

group :development do
  gem 'annotate'
end

 
インストールします。

 % bundle install

 
annotate をセットアップします。

% bin/rails g annotate:install
      create  lib/tasks/auto_annotate_models.rake

 
Railsの起動ポートを修正します。

# config/puma.rb

port ENV.fetch("PORT") { 3801 }

 

Deviseのセットアップ

インストールします。

% bin/rails g devise:install
      create  config/initializers/devise.rb
      create  config/locales/devise.en.yml

 
次にログインユーザーを管理するモデルの作成です。

OIDCの時は user だったため、今回はDeviseのREADMEにある通り member を使ってみます。

% bin/rails g devise member 
      invoke  active_record
      create    db/migrate/20220329113642_devise_create_members.rb
      create    app/models/member.rb
      invoke    test_unit
      create      test/models/member_test.rb
      create      test/fixtures/members.yml
      insert    app/models/member.rb
       route  devise_for :members

 
AS兼RSアプリで使うDeviseの機能は

  • ユーザー登録
  • ログイン

だけなため、自動生成されたマイグレーションファイル db/migrate/<TIMESTAMP>_devise_create_members.rb を手動で修正します。

# frozen_string_literal: true

class DeviseCreateMembers < ActiveRecord::Migration[7.0]
  def change
    create_table :members do |t|
      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""
      
      t.timestamps null: false
    end

    add_index :members, :email,                unique: true
  end
end

 
合わせて、 app/models/member.rb のMemberモデルにあるDeviseの機能も最低限にします。

class Member < ApplicationRecord
  devise :database_authenticatable, :registerable, :validatable
end

 

doorkeeperのセットアップ

公式ドキュメントに従いセットアップします。
https://doorkeeper.gitbook.io/guides/ruby-on-rails/getting-started

 

install

% bin/rails generate doorkeeper:install
      create  config/initializers/doorkeeper.rb
      create  config/locales/doorkeeper.en.yml
       route  use_doorkeeper

 

マイグレーションファイルを作成・調整

% bin/rails generate doorkeeper:migration
      create  db/migrate/20220329114138_create_doorkeeper_tables.rb

 
作成したdoorkeeper用のマイグレーションファイルの末尾にある以下について、アンコメントした後にDeviseの認証で使用しているテーブル(members)を指定します。

# Uncomment below to ensure a valid reference to the resource owner's table
add_foreign_key :oauth_access_grants, :members, column: :resource_owner_id
add_foreign_key :oauth_access_tokens, :members, column: :resource_owner_id

 

doorkeeperの設定変更

config/initializer/doorkeeper.rb に対して設定変更を行います。

有効な内容は以下の通りとします。

# frozen_string_literal: true

Doorkeeper.configure do
  orm :active_record
  
  resource_owner_authenticator do
    # ASでの認証にdeviseを使っているため、doorkeeperのドキュメントに従って設定
    # https://doorkeeper.gitbook.io/guides/ruby-on-rails/configuration
    current_member || warden.authenticate!(scope: :member)
  end

  # ASのユーザーは、全員OAuth Applicationを操作可能とする
  admin_authenticator do
    current_member || warden.authenticate!(scope: :member)
  end

  # 認可コードは30秒だけ有効
  authorization_code_expires_in 30.seconds

  # アクセストークンは1分だけ有効
  access_token_expires_in 1.minute

  # リフレッシュトークンを発行するため、古いアクセストークンは無効にする
  revoke_previous_client_credentials_token

  # アクセストークンに加え、リフレッシュトークンも発行する
  use_refresh_token

  # 今回はOAuth2.0のためscopeは任意の値で良いので適当に `read` とする
  default_scopes  :read

  # 今回は認可コードグラントフローだけ使う
  # なお、doorkeeperデフォルトは認可コードグラントフローとクライアントクレデンシャルズグラントフロー
  grant_flows %w[authorization_code]
end

 

AS兼RSのデータ取得用API向けのモデルを作成

モデル member に対して外部キーを持つモデル memo を生成します。

memo モデルはデータ取得用APIで使います。

% bin/rails generate model memo favorite:string member:references
      invoke  active_record
      create    db/migrate/20220329121510_create_memos.rb
      create    app/models/memo.rb
      invoke    test_unit
      create      test/models/memo_test.rb
      create      test/fixtures/memos.yml

 
念のため、 app/models/memo.rb を確認しますが、 member への belongs_to が自動で設定されています。

class Memo < ApplicationRecord
  belongs_to :member
end

 

AS兼RSのデータ取得用APIの作成

コントローラを作成

app/controllers/api/memos_controller.rb としてコントローラを作成します。

このコントローラでは、Doorkeeperのドキュメントに従い

  • アクセストークンがOKであること
    • before_action :doorkeeper_authorize!
  • アクセストークンを元に member からアクセスしてきたユーザーを特定すること
    • @member ||= Member.find(doorkeeper_token.resource_owner_id) if doorkeeper_token

を実装します。
Securing the API - doorkeeper

class Api::MemosController < ApplicationController
  before_action :doorkeeper_authorize!

  def index
    unless current_resource_owner
      return render json: {message: 'deny'}
    end

    memo = Memo.find_by(id: current_resource_owner.id)

    render json: {message: memo ? memo.favorite : 'not found'}
  end

  private def current_resource_owner
    @member ||= Member.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
  end
end

 

ルーティングの作成

API用のルーティングを config/routes.rb に追加します。

また、 DoorkeeperやDeviseのroutesも存在するか確認しておきます。

Rails.application.routes.draw do
  use_doorkeeper
  devise_for :members

  namespace :api do
    resources :memos, only: [:index]
  end
end

 

データの準備

マイグレーションの実行

% bin/rails db:migrate

 

seedの作成と実行

AS兼RSで使う member および memo 用のseedを作成します。

member_foo = Member.new(email: 'foo@example.com', password: 'password')
member_foo.save!

member_bar = Member.new(email: 'bar@example.com', password: 'password')
member_bar.save!

Memo.create(favorite: 'ふじ', member: member_foo)
Memo.create(favorite: 'シナノゴールド', member: member_bar)

 
実行します。

% bin/rails db:seed

 
これでAS兼RSアプリの準備は完了です。

 

Clientアプリの作成

初期セットアップ

rails new します。

% bundle exec rails new oauth_client --minimal

 
必要なものをGemfileに追加

# 追加
# omniauthまわり
gem 'omniauth'
gem 'omniauth-oauth2'

# OmniAuthのWikiにある通り、CSRF対策として追加
gem 'omniauth-rails_csrf_protection'

# リソースサーバーからデータを取得するために使用
# oauth2 gemの依存として使われているが、明示的に記載
gem 'faraday'

# OPからもらった client_id と client_secret を .env ファイルから読み込むために追加
gem 'dotenv-rails', groups: [:development, :test]

# modelにスキーマコメントを追加
group :development do
  gem 'annotate'
end

 
gemをインストールします。

% bundle install

 

セッションストアをActiveRecordにする

OIDCの時同様、 activerecord-session_store でセッションストアをActive Record にしたため、READMEに従い設定を追加します。 https://github.com/rails/activerecord-session_store

 
マイグレーションファイルを作成します。

% bin/rails generate active_record:session_migration

 
config/initializer/session_store.rb を作成して設定を行います。

# ActiveRecordにセッションを保存
Rails.application.config.session_store :active_record_store, :key => '_my_app_session'

 
セッションストアの中身を分かりやすくするよう、JSON形式でデータを保存するようにします。

config/application.rb の末尾に以下を追加します。

# 中身を見やすくするよう、JSON形式で保存
ActiveRecord::SessionStore::Session.serializer = :json

 

起動ポートの修正

クライアントは 3802 で起動します。

# config/puma.rb

port ENV.fetch("PORT") { 3802 }

 

OmniAuthのストラテジーを追加

OmniAuth OAuth2のREADMEに従い、ファイル lib/omniauth/strategies/my_as.rbOmniAuth::Strategies::OAuth2 を継承したストラテジーを作成します。
https://github.com/omniauth/omniauth-oauth2

このストラテジーでは、AS兼RSの情報を記載します。

なお、client_optionsの authorize_urltoken_url はDoorkeeperで自動生成される各エンドポイントを指定します。

require 'omniauth-oauth2'

module OmniAuth
  module Strategies
    class MyAs < OmniAuth::Strategies::OAuth2
      option :name, 'my_as'

      option :client_options, {
        site: 'http://localhost:3801',
        authorize_url: '/oauth/authorize',  # 認可エンドポイント
        token_url: '/oauth/token'  # トークンエンドポイント
      }

      # scope=read としてリクエスト
      option :scope, 'read'
    end
  end
end

 

OmniAuthのinitializerを追加

OmniAuthのREADMEにある Getting Started に従い、 config/initializers/omniauth.rb を作成します。
https://github.com/omniauth/omniauth#getting-started

この initializer ではClientアプリの情報を設定します。

require 'omniauth/strategies/my_as'

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :my_as,
           ENV['CLIENT_ID'],
           ENV['CLIENT_SECRET'],
           request_path: '/auth/my_as',
           callback_path: '/oauth_callbacks'
end

 
なお、 request_pathcallback_path の違いは以下のとおりです。

  • request_path
    • 認可コードグラントフローを開始するためのパス
    • OmniAuthでハンドリングしてくれるため、 config/routes.rb に記載する必要はない
  • callback_path
    • 認可コードグラントフローのリダイレクトエンドポイント
    • OmniAuthでハンドリングしてくれないため、 config/routes.rb に記載する必要がある

 

Deviseのセットアップ

Clientでもログインが必要になるため、AS兼RSと同様なセットアップを行います。

インストールします。

% bin/rails g devise:install
      create  config/initializers/devise.rb
      create  config/locales/devise.en.yml

 
Clientで管理するモデルを作成します。

ASとは別のモデル User にしてみます

% bin/rails g devise user 

 
マイグレーションもログインできるだけにします。

# db/migrate/<TIMESTAMP>_devise_create_users.rb

# frozen_string_literal: true

class DeviseCreateUsers < ActiveRecord::Migration[7.0]
  def change
    create_table :users do |t|
      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""

      t.timestamps null: false
    end

    add_index :users, :email,                unique: true
  end
end

 
Userモデルも修正します。

# app/models/user.rb 

class User < ApplicationRecord
  devise :database_authenticatable, :registerable, :validatable
end

 

AS兼RSから渡されたアクセストークンなどを保存するモデルを作成

認可コードグラントフローが終わると、AS兼RSから

などが渡されます。

アクセストークンなどは再利用したいため、今回はDBに保存することにします。

% bin/rails generate model authz access_token:string refresh_token:string expires_at:integer user:references

 

リダイレクトエンドポイント用のコントローラを作成

リダイレクトエンドポイントで何をするかはClientアプリで実装する必要があります。

app/controllers/oauth_callbacks_controller.rb として作成し、クレデンシャル情報をモデルに保存する処理を実装します。

class OauthCallbacksController < ApplicationController
  def index
    # AS兼RSアプリから受け取ったクレデンシャル情報を取得
    credentials = request.env['omniauth.auth'].credentials

    # クレデンシャル情報をモデルに保存
    authz = Authz.find_or_initialize_by(id: current_user.id) do |a|
      a.user = current_user
    end

    authz.update(
      access_token: credentials.token,
      refresh_token: credentials.refresh_token,
      expires_at: credentials.expires_at
    )

    # 処理が終わったらルートにリダイレクト
    redirect_to root_path
  end
end

 

AS兼RSアプリから取得した情報を表示するコントローラとビューを作成

今回は

  • ログインしているかどうか
  • AS兼RSアプリで認可処理をしているかどうか
  • AS兼RSアプリからデータを取得できているかどうか

を確認できるようなコントローラとビューを作成します。

 

コントローラの作成

app/controllers/homes_controller.rb として作成します。

なお、実装するときにいくつか調べたことをメモします。

 

Faradayでアクセストークンをリクエストに乗せる方法について

まず、OmniAuthではアクセストークンの取得までしか行わないため、アクセストークンを使ってデータを取得するところは自分で実装する必要があります。

そこで今回はFaraday + アクセストークンを使ってデータを取得するよう実装します。

なお、Faraday 1.x系の場合は、別途 faraday_middleware というgemを追加して、アクセストークンをリクエストに乗せてあげる必要がありました。
faraday_middleware/oauth.md at main · lostisland/faraday_middleware

ただ、Faraday 2系からはFaradayだけでアクセストークンをリクエストに乗せることができるようになりました。
Authentication Middleware | Faraday

private def fetch_resource_server
  conn = Faraday.new do |conn|
    conn.request :authorization, 'Bearer', @access_token
  end

  conn.get('http://localhost:3801/api/memos/')
end

 

リフレッシュトークンを使ってアクセストークンを更新する方法について

上記の通り、Faradayではアクセストークンをリクエストに乗せることはできます。

一方、リフレッシュトークンを使ってアクセストークンを更新することはできないようです。

調べてみたところ、 omniauth-oauth2 gemが依存している oauth2 gem を使えば良さそうでした。

 
そこで、以下のようなprivateメソッドを用意して、アクセストークンを更新するようにしてみました。

private def renew_access_token(authz)
  strategy = OmniAuth::Strategies::MyAs.new(
    nil,
    ENV['CLIENT_ID'],
    ENV['CLIENT_SECRET'],
    )
  client = strategy.client

  token = OAuth2::AccessToken.new(
    client,
    authz.access_token,
    { refresh_token: authz.refresh_token }
  )

  new_credentials = token.refresh!

  authz.update(
    access_token: new_credentials.token,
    refresh_token: new_credentials.refresh_token,
    expires_at: new_credentials.expires_at
  )

  @is_renew = true

  new_credentials.token
end

 

コントローラ全体の実装

エラーハンドリングまわりが甘いですが、今回は試すだけなので気にしないこととします。

class HomesController < ApplicationController
  before_action :authenticate_user!

  def index
    # アクセストークンを取得する
    authz = Authz.find_by(id: current_user.id)

    @access_token = authz.present? ? authz.access_token : nil
    @memos = nil
    @is_renew = false

    if @access_token.present?
      response = fetch_resource_server

      case response.status
      when 200
        @memos = parse_response_body(response.body)

      when 401
        # アクセストークンが不正の場合、リフレッシュトークンを使って新しいアクセストークンを取得する
        @access_token = renew_access_token(authz)

        new_response = fetch_resource_server
        # アクセストークンをリフレッシュしたとしてもエラーになる可能性はあるが、今回は気にしない
        @memos = parse_response_body(new_response.body)

      else
        # TODO: 本番運用する時は真剣に考えること
        # エラーっぽいので、エラーメッセージを入れておく
        @memos = 'エラーです'
      end
    end
  end

  private def fetch_resource_server
    conn = Faraday.new do |conn|
      conn.request :authorization, 'Bearer', @access_token
    end

    conn.get('http://localhost:3801/api/memos/')
  end

  private def parse_response_body(response_body)
    JSON.parse(response_body)['message']
  end

  private def renew_access_token(authz)
    strategy = OmniAuth::Strategies::MyAs.new(
      nil,
      ENV['CLIENT_ID'],
      ENV['CLIENT_SECRET'],
      )
    client = strategy.client

    token = OAuth2::AccessToken.new(
      client,
      authz.access_token,
      { refresh_token: authz.refresh_token }
    )

    new_credentials = token.refresh!

    authz.update(
      access_token: new_credentials.token,
      refresh_token: new_credentials.refresh_token,
      expires_at: new_credentials.expires_at
    )

    @is_renew = true

    new_credentials.token
  end
end

 

ビューの作成

app/views/homes/index.html.erb として作成します。

なお、認可処理を開始するためのフォームは、OmniAuthのWikiに従い methodPOST にしています。
Resolving CVE 2015 9284 · omniauth/omniauth Wiki

<h1>Homes#index</h1>

<% if user_signed_in? %>
  <p><%= current_user.email %>でログインしています</p>

  <% if @access_token %>
    <p>初回の認可処理も実施済です</p>

    <% if @is_renew %>
      <p>アクセストークンの更新を行いました</p>
    <% end %>
  <% else %>
    <p>認可処理がされていないので、以下のボタンを押して認可処理を行ってください</p>
    <%= form_tag('/auth/my_as', method: 'post') do %>
      <button type='submit'>認可処理をする</button>
    <% end %>
  <% end %>

<% else %>
  <a href="<%= new_user_session_path %>">ログインする</a>
<% end %>

<hr>
<p>memo: <%= @memos %></p>

 

ルーティングの作成

AS兼RSアプリから取得した情報を表示するのは root_path とするため、上記で作成したコントローラとメソッドを指定します。

また、OAuthのリダイレクトエンドポイント用のルートも用意します。

Rails.application.routes.draw do
  devise_for :users
  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

  # Defines the root path route ("/")
  root 'homes#index'

  # OAuth2.0のリダイレクトエンドポイント用
  resources :oauth_callbacks, only: [:index]
end

 

データの準備

マイグレーションの実行

% bin/rails db:migrate

seedの作成と実行

SeedではClientアプリにログインするユーザーを生成します。

なお、AS兼RSでのログイン特別するため、パスワードは別にしています。

user_foo = User.new(email: 'foo@example.com', password: 'client')
user_foo.save!

user_bar = User.new(email: 'bar@example.com', password: 'client')
user_bar.save!

 
seedを実行します。

% bin/rails db:seed 

 
以上でClientアプリも準備ができました。

 

動作確認

AS兼RSアプリとClientアプリを起動し、動作を確認します。

 

AS兼RSアプリにて、OAuthアプリケーションの登録

以下のURLにアクセスし、AS兼RSアプリにClientアプリの情報を登録します。
http://localhost:3801/oauth/applications

 
登録する内容は以下のとおりです。

項目
Name 任意( my_as など)
Redirect URI http://localhost:3802/oauth_callbacks
Confidential チェックを入れる
Scopes read

 
登録後、クライアントIDとクライアントシークレットが表示されます。

 

Clientアプリの .env ファイルに、クライアントIDとクライアントシークレットを記載

Clientアプリのプロジェクトルートに .env ファイルを用意して、それぞれ記載します。

CLIENT_ID=***
CLIENT_SECRET=***

 

全体の動作確認

Clientアプリのルートにアクセスします。
http://localhost:3802/

すると、ログインが必須なため、ログイン画面が表示されます。

 
ログインすると、認可処理が必要な旨が表示されており、データは表示されていません。そこで、 認可処理をする ボタンをクリックします。

 
すると、AS兼RSアプリのログイン画面に遷移しますので、こちらでもログインします。

 
次は「認可してもよいか」が表示されるため、 Authorize をクリックします。

 
すると Clientアプリに戻り、AS兼RSのデータ取得APIから取得したデータが表示されています。

 
アクセストークンの有効期限は1分ですので、1分以上経過後にリロードすると、アクセストークンを更新した旨も表示されます。

 
以上より、Rails + Devise + OmniAuthにて、Deviseで認証を、OmniAuthでOAuth2.0の認可コードグラントフローの認可だけを扱えました。

 

参考

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_omniauth_without_login-sample