💎

omniauth-oauth2+Devise+RailsでMicrosoft Graphを使用したSSOを実装する

2023/12/19に公開

挨拶

どうも、新卒でweb業界に入って6ヶ月, なんとかしがみついて生きています。この6ヶ月で色々なことを学びましたが、どうやらエンジニアは自走したり、アウトプットを積極的に行う訓練された民族である、ということを強く実感しました。自分もその一員になるため、今回の記事を書きました。

本記事は、Fusic アドベントカレンダー(2枚目)の19日目の記事になります!前日は@funassyさんの記事、次の日は@ayasamindさんの記事です!

動機

SSO、便利ですよね。自分も色々なサービスを「〇〇(サービス名)で登録」でサインインできるの、楽ですよね。フォームとの戦いから解放されるのは、サイコーですよね。

そんな安易な考えから、「自分もパスワード認証ではなくて、SSOでユーザーの認証してみたい!」と思い、実際にRailsアプリに組み込んでみたので、備忘録を書きます!

参考

全てのやり方は、以下の二つのチュートリアル&Wikiに書いてあります!

また、全体的な方針はこちらの記事と同様な進め方になっています。とても参考になるので、ご覧ください。
RailsにDevise+Omniauthでユーザー認証したい
同様に、こちらの記事も大変勉強になりますので、一読されることをお勧めします。
OmniAuth OAuth2 を使って OAuth2 のストラテジーを作るときに知っていると幸せになれるかもしれないこと

ゴール

  • Rails, devise, omniauth-oauth2でユーザーの認証を実装する
  • OAuth2を用いてMicrosoft GraphのAPI認証をする
  • 単一のプロバイダで実装する(マルチプロバイダ対応はしない)
  • OmniauthのStrategyを触る

前提

今回は、以下の前提のもと進めさせていただきます。

  • Ruby 3.2.1
  • Rails 7.0
  • 使用した主要なGem: omniauth-oauth2, devise
  • 公開されているStrategyのGemは使用しない: omniauth - List-of-Strategies
  • rails newは終了していて、認証用のUserモデル、deviseは導入済み
  • ユーザーの認証はSSOによるものだけに限定(パスワードによるログインはできない)
    ↑パスワード認証も可能なアプリにする場合、ユーザーにパスワードを設定するフェーズを挟む必要があると想像しています。ここの考え方は、こちらの記事が参考になります。Devise/OmniAuthを使ってSNSログイン実装しました系記事で気になる3つのこと

順番

今回の記事では、以下の順番で進めさせていただきます。

  • omniauth-oauth2の設定
  • deviseの設定
  • Rails側, Migration, Modelの設定
  • Rails側, Controller, strategyの設定
  • Rails側, Viewの設定
  • Azure側設定
  • 動作確認
  • Strategy, Modelを編集して,特定APIから取得したデータを利用する
  • (option)サブディレクトリを用いる時のcallback urlの設定
  • (option)シンボルの名前の影響範囲
  • 終わり

omniauth-oauth2の設定

まず初めに, omniauth-oauth2というGemをインストールします。このGemを元にして、OAuth2.0を使用した認証の下地を作っていきます。必要なGemは以下の二つです。

Gemfile
# OAuth
gem 'omniauth-oauth2', '~> 1.7.1'
# OmniAuth CSRF protection
gem 'omniauth-rails_csrf_protection', '~> 1.0.0'

ここで、著名なproviderはgemとして提供されていることが殆どです。そちらを使用する場合は、こちらのリンクを参照してください。

deviseの設定

devise側の設定は、とてもシンプルです。
deviseのWikiを見ると、config/initializers/devise.rbに以下の設定を記載します。

config/initializers/devise.rb
config.omniauth :microsoft_graph_auth, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo'

今回、使用するプロバイダはMicrosoft Graphとします。シンボルで記載した名前は、:microsoft_graph_authとなっていますが、こちらは後述するstrategyの方で名前を指定します。皆様の好きな任意の名前をつけてください。APP_ID, APP_SECRET, scopeの文字列に関しては, 後述するAzure側設定の章で取得した物を使用します。また、これらの値はgithubなどのリモートリポジトリには含めたくない場面がほとんどなため、.envファイルや、credentials.ymlを使用して適宜修正してください。

Rails側, Migration, Modelの設定

次に、Rails側でMicrosoft_graph APIで取得した値を保存するカラムを, Userモデルに追加します。今回は、すでにパスワードによる認証をdeviseを使用して構築していて、deviseで認証に使用しているUser Modelに追加する形で話を進めます。deviseを使用してUserモデルを作成していない方は、deviseの公式サイトを元にして、Userモデルを作成してください。

今回追加するカラムは、providerとuidになります。以下のコマンドをプロジェクトのルートディレクトリで打ち込んで追加が可能です。

rails g migration AddOmniauthToUsers provider:string uid:string
rake db:migrate
# rails db:migrateでも。 bundle execは省略しています

これにより、Userモデルにstring型のuid, providerカラムが追加されます。自分の認識だと、uidがユーザーに一意に与えられたidを格納するカラムで、providerが認証のためのプロバイダ名を保存するカラムになります。(OAuth2を用いてAPIを叩いた際のscopeの中身を元にそう考えています)マイグレーションが終了した後、スキーマファイルにカラムが追加されていることを確認してください。

次に、app/models/user.rbに:omniauthableを追加します。パスワード認証をする為にdeviseを使用した場合、rememberable, confirmable等の記載がすでにあると思うので、その後ろに追加します。

app/models/user.rb
devise :他の○○able, :omniauthable

Rails側, Controllerの設定

config.rbdevise_for :usersが設定されている場合、以下の二つのurlメソッドが自動的に生成されているはずです。rails routesを使用して、確認してみてください。これらのメソッドは我々が今回使用することはありませんが、自分でカスタマイズする際には有用です。

user_{provider}_omniauth_authorize_path
user_{provider}_omniauth_callback_path

上は、認証ボタンが押された時に遷移するパス(/users/auth/microsoft_graph_auth)、下は、認可サーバがリダイレクトさせる先のコールバックのパス(/users/auth/microsoft_graph_auth/callback)となっています。下で生成されるパスに、localhostをつけたもの(http://localhost:{任意のポート}/users/auth/microsoft_graph_auth/callback)は、Azure portalでの設定で利用するため、保存しておきましょう。

さて、これらのルートに遷移した際、特定の動作を行うためにはコントローラーが必要になります。よって、app/controllers/users/omniauth_callbacks_controller.rbを作成します。

omniauth_callbacks_controller.rb
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
end

deviseのWikiを見ると、以下のコードが記載されています。

omniauth_callbacks_controller.rb
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  # See https://github.com/omniauth/omniauth/wiki/FAQ#rails-session-is-clobbered-after-callback-on-developer-strategy
  skip_before_action :verify_authenticity_token, only: :facebook

  def facebook
    # You need to implement the method below in your model (e.g. app/models/user.rb)
    @user = User.from_omniauth(request.env["omniauth.auth"])

    if @user.persisted?
      sign_in_and_redirect @user, event: :authentication # this will throw if @user is not activated
      set_flash_message(:notice, :success, kind: "Facebook") if is_navigational_format?
    else
      session["devise.facebook_data"] = request.env["omniauth.auth"].except(:extra) # Removing extra as it can overflow some session stores
      redirect_to new_user_registration_url
    end
  end

  def failure
    redirect_to root_path
  end
end

今回はこのコードを一部参考にしつつ、microsoftのチュートリアルのコードも組み合わせてcontrollerの構築を行っていきます。

このコールバック部で、APIから得られたデータを使ったユーザーの作成や、ユーザーの確認、そしてサインインやエラー時のリダイレクトを設定します。まず、APIが認証されて、データが正しく送られてきていることを確認したいため、下のようにコードを作成します。

class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  # コールバックURLにリダイレクトされた時に呼ばれるaction
  def callback
    microsoft_graph_auth
  end
  
  def microsoft_graph_auth
    # OmniAuthから提供されるハッシュを取得する
    data = request.env['omniauth.auth']

    # jsonのフォーマットで得られたデータを描画するコード
    render json: data.to_json
  end
  
  # 何らかが原因で失敗した時にルートにリダイレクトさせる
  def failure
    redirect_to root_path
  end
end

私の場合は、こんな感じに記載しました。

omniauth_callbacks_controller.rb
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  # See https://github.com/omniauth/omniauth/wiki/FAQ#rails-session-is-clobbered-after-callback-on-developer-strategy
  skip_before_action :verify_authenticity_token, only: :microsoft_graph_auth

  # ここがコールバックされた後に呼ばれるアクション、という認識
  def microsoft_graph_auth
    callback_from(:microsoft_graph_auth)
  end

  # 実際の処理はこちら
  def callback_from(_provider)
    # You need to implement the method below in your model (e.g. app/models/user.rb)
    auth = request.env['omniauth.auth']
    if uid_or_provider_nil?(auth)
      return redirect_to root_path,
                         notice: t('users.omniauth_callbacks')
    end

    @user = User.from_omniauth(auth)

    if @user.persisted?
      sign_in_and_redirect @user, event: :authentication # this will throw if @user is not activated
    else
      # 自分のアプリケーションではここに遷移することはなかったが、Azureに登録されているアプリにが存在している組織にMicrosoftアカウントがないユーザーは、こちらに飛ばされる仕様(たとえば個人のアカウントでLoginした時)
      redirect_to new_user_session_path
    end
  end

  def failure
    # flashメッセージなどを記載して、ログインページでお知らせするのも良い
    redirect_to root_path
  end

  private

  def uid_or_provider_nil?(auth)
    auth.uid.nil? || auth.provider.nil?
  end
end

※ Azure側で、同一組織に属しているアカウントのみがこのアプリケーションを使用可能にする設定は、登録したアプリの「管理」->「認証」から、「サポートされているアカウントの種類」で設定できる認識です。

次に、routes.rbに上記controllerを指定します。具体的には以下を参照してください。

routes.rb
devise_for :users, controllers: { omniauth_callbacks: 'users/omniauth_callbacks' }

Rails側, Strategyの設定

Strategyの記述に移りましょう。こちらもMicrosoftのtutorialを参考にして構築します。Microsoftのチュートリアルでは、以下のように記載されています。

lib/microsoft_graph_auth.rb
require 'omniauth-oauth2'

module OmniAuth
  module Strategies
    # Implements an OmniAuth strategy to get a Microsoft Graph
    # compatible token from Azure AD
    class MicrosoftGraphAuth < OmniAuth::Strategies::OAuth2
      # ここのoptionのnameに記載したものを、config/initializer/devise.rbに設定
      #omniauth_callback_controller.rbにも
      option :name, :microsoft_graph_auth
      
      DEFAULT_SCOPE = 'openid email profile User.Read'.freeze

      # Configure the Microsoft identity platform endpoints
      # 修正する
      option :client_options,
             :site => 'https://login.microsoftonline.com',
             :authorize_url => '/common/oauth2/v2.0/authorize',
             :token_url => '/common/oauth2/v2.0/token'

      # Send the scope parameter during authorize
      option :authorize_options, [:scope]

      # Unique ID for the user is the id field
      uid { raw_info['id'] }

      # Get additional information after token is retrieved
      extra do
        {
          'raw_info' => raw_info
        }
      end

      # 修正する
      def raw_info
        # Get user profile information from the /me endpoint
        @raw_info ||= access_token.get('https://graph.microsoft.com/v1.0/me?$select=displayName,mail,mailboxSettings,userPrincipalName').parsed
      end

      def authorize_params
        super.tap do |params|
          params[:scope] = request.params['scope'] if request.params['scope']
          params[:scope] ||= DEFAULT_SCOPE
        end
      end

      # Override callback URL
      # OmniAuth by default passes the entire URL of the callback, including
      # query parameters. Azure fails validation because that doesn't match the
      # registered callback.
      def callback_url
        options[:redirect_uri] || (full_host + script_name + callback_path)
      end
    end
  end
end

上記の中で、# 修正すると記載した部分を修正します。

まず、認可・トークンエンドポイントのURLを生成する部分から始めます。今回、commonの部分にテナントIDを格納したいので、以下のようにコードを変更します。

lib/microsoft_graph_auth.rb
# Configure the Microsoft identity platform endpoints

# 環境変数からtenant idを取得して、commonに代入する
tenant_id = ENV.fetch('AZURE_TENANT_ID', 'common')

# commonをtenant_idに変更する
option :client_options,
     :site => 'https://login.microsoftonline.com',
     :authorize_url => '/#{tenant_id}/oauth2/v2.0/authorize',
     :token_url => '/#{tenant_id}/oauth2/v2.0/token'

テナントIDは、devise.rbに記載するシークレットと同様、環境変数に記載して必要な部分で利用します。

次に、raw_infoのメソッドを修正します。ここのselect以降のクエリが、我々の望むものになっていないため、修正します。今回、uidを利用するので、以下のようにコードを修正します。こちらのドキュメントに呼び出しの仕様が記載されています。また、ここで指定しているものが、自分がazure portal上で許可したAPIに対応しているか確認してください。自分は、許可をしていないものがある状態でselectクエリに無駄な値を入れ要求していて、それで半日ほど溶かしました。

lib/microsoft_graph_auth.rb
def raw_info
        # Get user profile information from the /me endpoint
        @raw_info ||= access_token.get('https://graph.microsoft.com/v1.0/me?$select=displayName,mail,userPrincipalName,id,uid').parsed
end

私は自分のアプリケーションの都合上、上記のような選択としています。uidが追加で欲しいため章頭のコードにuidを追加しています。この部分は、自分が利用するAPIによって記載内容を変更してください。

azure portal上のAPI許可のページ

Rails側, Viewの設定

認可サーバにアクセスするためのボタンを作成します。私は事前にbundle exec rails g devise:views usersを実行していたため、app/views/users/sessions/new.html.erbが生成されており、その中で使用されている_links.html.erbパーシャルの内容を用いてnew.html.erbの内容を書き換えております。なので、動作確認だけを行うのであれば、上記のbundle exec以降のコマンドを実行して動作の確認をすることが可能です。

以下、サンプルコードです。スタイリングはbootstrapをかけています。

views/users/sessions/new.html.erb
<div class="container">
  <div class="text-center">
    <%- if devise_mapping.omniauthable? %>
      <%- resource_class.omniauth_providers.each do |provider| %>
      <%= button_to t('.login'),
                  omniauth_authorize_path(resource_name, provider),
                  class: 'btn btn-primary btn-lggit ad', data: { turbo: false } %>
      <% end %>
    <% end %>
  </div>
</div>

Azure側設定

Azure portal側での設定を行います。Azure portalのMicrosoft Entra IDの中から、「アプリの登録」を選択します。アプリの登録にて、以下の画像のように設定します。アプリの名前は自由に決めてください。また、リダイレクトURIでは、プラットフォームに「Web」を指定して、URIに先ほどRails側で構築したコールバックアクションのURIを指定してください。

アプリケーションの登録が終了すると、以下のようなweb画面にリダイレクトされます。(2023年10月の状態)

動作確認

さて、動作の確認をしましょう!まずはログインするためにrailsサーバーを起動してください。この時、envやクレデンシャルを使用している方は値がセットされているか、Railsが読み込んでいるかを確認してください。また、ローカルでチェックする際は、コールバックURIに設定しているポートもあっているか確認してください。正常に作れている場合、以下の様なログイン画面が出ます。画面が出たら、承認エンドポイントまでたどりつけているということです。

これを進めていくと、同意画面が出るので、同意を行います。すると、自分で設定したコールバックのアクションに飛ばされることがわかります。この時、binding.irbやdebuggerなどで動作を止めると、クエリの中に認可コードがあることが確認されます。我々はこの値を直接触ることはないですが、この値を使用して、いい感じにOmniauthがやってくれているというわけです。今回、セッションはdeviseに任せたままなので、tokenへのアクセスは行いません。tokenを使用した場合に、deviseとどんな感じに共存させるのかは自分もまだ知らないところなので、何か良い資料や記事などあれば教えてくださると助かります。 microsoftのチュートリアルの下の方に記載してありました!参考

Strategy, Modelを編集して,特定APIから取得したデータを利用する

さて、ここまでくればmicrosoft graphのAPIを使用していろんなデータを取得することが可能です。
先ほどraw_infoで指定した値は、callbackの中で使用することが可能です。

def raw_info
        # Get user profile information from the /me endpoint
    @raw_info ||= access_token.get('https://graph.microsoft.com/v1.0/me?$select=displayName,mail,userPrincipalName,id,uid').parsed
end

omniauth_callback_controllerをもう一度みてみましょう。

def callback_from(_provider)
    # ここで、APIから取得した値を引き出している。
    auth = request.env['omniauth.auth']
    if uid_or_provider_nil?(auth)
      return redirect_to root_path,
                         notice: t('users.omniauth_callbacks')
    end

    @user = User.from_omniauth(auth)

  if @user.persisted?
    sign_in_and_redirect @user, event: :authentication # this will throw if @user is not activated
  else
    # 自分のアプリケーションではここに遷移することはなかったが、Azureに登録されているアプリにが存在している組織にMicrosoftアカウントがないユーザーは、こちらに飛ばされる仕様(たとえば個人のアカウントでLoginした時)
    redirect_to new_user_session_path
  end
end

今回は、ユーザーの情報だけ見たかったので、request.envからomniauth.authを取得しています。そして、既存ユーザーの取得or作成をするために、Userモデルからfrom_omniauthメソッドを呼んでいます。

from_omniauthメソッドはこんな感じです。

user.rb
def self.from_omniauth(auth)
  attributes = {
    provider: auth.provider,
    uid: auth.uid,
    password: UserUtils.generate_password,
    name: auth.extra.raw_info.display_name,
    email: UserUtils.email_from_authhash(auth),
  }

  find_or_create_by(uid: auth.uid, provider: auth.provider) do |user|
    attributes.each { |key, value| user.send("#{key}=", value) }
  end
end

ところどころ存在するUserUtilsは、authの複雑なハッシュからデータを抽出するためのメソッドです。authのハッシュの中身も一読に値すると個人的に思っていますので、お暇があればbinding.irbとかで止めてみてご覧になってください。

この様にして、microsoft graphから取得したユーザー情報で、ユーザーの作成orユーザーの取得を行っています。microsoftのチュートリアルでは, outlookのカレンダーから情報を取得するサンプルもあるので、純粋にAPIを使用する場合はそちらを参考にしてください。

(option)サブディレクトリを用いる時のcallback urlの設定

サブディレクトリでアプリケーションを分けているサーバだったので、以下のような問題に当たりました。元々のcallback_urlだと、生成されるコールバックが, domain/applicaition_name/application_nameとなってしまう問題です。サブディレクトリはアプリケーション側からは知るよしもないので
options[:redirect_uri]で生成された値が返され、applicationの名前が二重になっていたというわけです。そのため、自分は以下の様にenvからコールバックURLを取得する形で実装をしています。

# Override callback URL
# OmniAuth by default passes the entire URL of the callback, including
# query parameters. Azure fails validation because that doesn't match the
# registered callback.
# サブディレクトリを使用すると, アプリケーション名が二重になってしまう。
def callback_url
  ENV.fetch('AZURE_CALLBACK_URL', full_host + script_name + callback_path)
end

(option)シンボルの名前の影響範囲

strategyのシンボルの名前を変更した時どこが影響するか調べました。たとえば:microsoft_graph_auth_testとした時に変更が必要となる部分は自分の環境では以下の部分になりました。

  • omniauth_callback_controllerのmicrosoft_graph_auth_testアクション名(該当するコントローラーに同名のアクションがないためエラーになる)
  • devise.rbのconfig.omniauthに指定するシンボル(記載したシンボルがエンドポイントの末に来るため、Not found. Authentication passthru.が出る)
  • strategyのファイルに記載するcallback_url(microsoft_graph_auth.rbファイル)

また、上記を変更した時点で、Azure側に記載するリダイレクトURIも変える必要が出てくると思います。(Microsoft Entra IDアプリの登録->{アプリ名}->管理->認証から)

終わり

ここまで読んでくださりありがとうございました。アドベントカレンダーはこれで二本目ですが、なかなか書きごたえのある記事になりました。今後、OpneIDConnectを使用することがありそうなので、それも機会があれば記事にしたいです。

Fusic 技術ブログ

Discussion