doorkeeper製 OAuth 2.0 のAuthorization Serverにて、色々な状態のアクセストークンを使って Introspection エンドポイントの挙動を確認してみた

2021/08/26 00:42 追記

ご指摘いただいた内容に合わせ、記事を修正しました。

主な修正点は以下の通りです。

  • タイトルをOAuth2.0な文脈へ変更
  • 環境に Resource Server を追加し、Resource Serverの中で Authorization Server の Introspection エンドポイントを呼び出すよう修正
    • これで、RS -> AS な挙動となったはず
    • それに合わせ、Client の中で行っていた Introspection エンドポイントの呼び出しを削除
  • OpenID Connect 向けの表記を OAuth 向けの表記に改めた
    • IntrospectionエンドポイントやRevokeエンドポイントは、 OAuth 2.0 のエンドポイントであるため
    • それに合わせ、 UserInfo エンドポイントでの挙動確認を取りやめ
    • その代わり、Resource Server のAPIを呼び出して挙動を確認する
      • RSのAPIの中で、Introspection エンドポイントによる access_token 検証をしている

2021/08/26 00:42 追記ここまで

2021/08/23 22:20 追記

この記事を公開したところ、Twitterにてご指摘をいただきました。ありがとうございました。

 
ご指摘の通り、IntrospectionエンドポイントがRS->ASではなくClient->ASとなっているため、この記事は適切ではありません。

RS->ASな形に記事を修正する予定ですが、ご注意ください。

2021/08/23 22:20 追記ここまで


 
以前、doorkeeper-openid_connectなどを使って、OpenID Connect の OpenID Provider と Relying Party を作成し、OpenID Connectでログインできるところまで作ってみました。
Railsとdoorkeeper-openid_connectやOmniAuth を使って、OpenID Connectの OpenID Provider と Relying Party を作ってみた - メモ的な思考的な

この時はログイン状態をセッションCookieで管理していたため、IDトークンの検証後は各種トークンを捨てていました。

 
そんな中、doorkeeperのWikiにて

  • Introspection エンドポイント
    • RFC7662 - OAuth 2.0 Token Introspection
  • Revocation エンドポイント
    • RFC7009 - OAuth 2.0 Token Revocation

など、アクセストークンの検証や取り消し用の OAuth 2.0 のエンドポイントについて記載されているのを見ました。
API endpoint descriptions and examples · doorkeeper-gem/doorkeeper Wiki

 
ここで、doorkeeperのデフォルトのアクセストークン形式は識別子型です。前回作成した時のアクセストークンも D3XwqTZYZV7xo3q12CTEUabY5a_z_-jKMpnmWXG75BQ のような識別子型でした。

 
そのため、もし今後

  • APIサーバなどの Resource Server を今の構成に追加
  • APIサーバの認可は、OAuth2.0 のアクセストークンで行う

を試す時

がある場合は、 Introspection・Revocationエンドポイントへのリクエストが発生しそうでした。

 
そこで今回、アクセストークンを

  • 渡された値そのまま
  • 不正なアクセストークンに差し替え
  • 有効期限切れ
  • Revocationエンドポイントで取り消し済

の各状態にして、Introspection・UserInfoエンドポイントの挙動を確認してみました。

 
目次

 

用語について

OAuth と OpenID Connect ではロール関連用語が異なります。

Auth屋さんの書籍「【電子版】OAuth、OAuth認証、OpenID Connectの違いを整理して理解できる本 - Auth屋 - BOOTH」の p36 から用語を引用すると、このような表になります。

No OAuthの場合 OpenID Connectの場合
1 Client Relying Party(RP)
2 Authorization Server (AS) OpenID Provider (OP)
3 Resource Server (RS) UserInfo エンドポイント

 
今回扱う

  • Revocationエンドポイント
  • Introspectionエンドポイント

は、両方ともOAuth関連のRFCとして定義されています。

そこで、今回の記事では OAuth のロール名で記載します。以前の記事とは異なりますので、ご注意ください。

ただし、ソースコードは以前の記事の続きであるため、ソースコード中の表記は OpenID Connect のロールになっています。

 

環境

  • macOS
  • Authorization Server (AS)
    • localhost:3780 で起動
    • Rails 6.1.4
    • doorkeeper 5.5.2
    • doorkeeper-openid_connect 1.8.0
    • devise 4.8.0
    • アクセストークンは doorkeeper のデフォルト形式
  • Client
    • localhost:3781 で起動
    • この Client から AS の Revocation エンドポイントへリクエストを出して、アクセストークンを取り消す
    • 認可コードフローで AS からアクセストークンをもらう
    • Rails 6.1.4
    • omniauth 2.0.4
    • omniauth-oauth2 1.7.1
  • Resource Server (RS)
    • localhost:3782 で起動
    • この RS から AS の Introspection エンドポイントへリクエストを出して、アクセストークンを検証する
    • Introspection用エンドポイントにアクセスするため、クライアントクレデンシャルフローで AS からアクセストークンをもらう
    • Rails 6.1.4
    • oauth2 1.4.7
      • クライアントクレデンシャルフローでASから access_token をもらう時に使用
    • faraday 1.7.0
      • ASの Introspection エンドポイントへアクセスする時に使用
  • アクセストークンの状態
    • ASから渡された値そのまま
    • 不正なアクセストークンに差し替え
    • 有効期限切れ
    • Revocationエンドポイントで取り消し済

 
なお、今回扱うソースコードは、この前の記事をベースにしたものです。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample

 

Authorization Server (AS) の設定変更

doorkeeperの allow_token_introspection を修正

今回、RSからASの Introspection エンドポイントへリクエストするため、 Introspection エンドポイントの利用を許可する設定を変更します。

設定ファイルでのコメントには

elsif token.application
  # `protected_resource` is a new database boolean column, for example
  authorized_client == token.application || authorized_client.protected_resource?

とあります。

ただ、 protected_resource というDB列は存在しないことから、 authorized_client == token.application だけを条件にします。

allow_token_introspection の全体はこんな感じになります。

allow_token_introspection do |token, authorized_client, authorized_token|
  if authorized_token
    authorized_token.application == token&.application ||
      authorized_token.scopes.include?("introspection")
  elsif token.application
    authorized_client == token.application  # 変更
  else
    true
  end
end

 

スコープを追加

OpenID Connectの場合はスコープとして openid が必要でした。

今回は Introspection も行うため optional_scopes に追加します。

Doorkeeper.configure do
# ...
  default_scopes :openid
  optional_scopes :introspection  # 追加
# ...

 

アクセストークンの有効期限を変更

今回アクセストークンの有効期限が切れたときの挙動も確認したいため、有効期限を短くして確認を容易にしてみます。

今回は1分で切れるよう、 config/initializers/doorkeeper.rbaccess_token_expires_in を修正します。

Doorkeeper.configure do
  # ...
  access_token_expires_in 1.minute
  # ..
end

 

Client の作成

前回とは別のRailsアプリを新規作成して Client としてもよいのですが、OmniAuthのストラテジーは一部修正すればそのまま使えそうでした。

また、以下の記事にあるように、OmniAuthでは1つのストラテジーを複数回使えそうでした。
同じストラテジーを複数回登録したい | OmniAuth OAuth2 を使って OAuth2 のストラテジーを作るときに知っていると幸せになれるかもしれないこと - Qiita

 
そこで、実用的ではないかもしれませんが、OmniAuthの動作確認もかねて、

  • 前回使ったClientのRailsアプリを流用
    • ストラテジーなどは必要な箇所だけ修正
  • 前回作成したOAuth Application はそのまま残す
    • ASで、別のOAuth Application として作成

の方針とします。

 

OmniAuthまわりの修正

.envファイルへの追記

別のOAuth Application として登録するため、 .env ファイルに

CLIENT_ID_OF_INTROSPECTION=
CLIENT_SECRET_OF_INTROSPECTION=

を追加します。

クライアントIDやシークレットの値は、後で AS から取得したものを設定します。

 

OmniAuthのストラテジーを修正

前回のストラテジーをほとんどそのまま流用できますが、IDトークンの検証で使う aud の値のクライアントIDが固定になっています。

そこで、ストラテジーを使った時のクライアントIDで検証できるよう、 .env ではなく options['client_id'] から取得するよう変更します。

修正対象のストラテジーのファイルは lib/omniauth/strategies/my_op.rb です。

def id_token_payload(id_token, subject_from_userinfo)
  payload, _header = JWT.decode(
    # ...
    { # options
      # ...
      # ストラテジーを使い回すため、client_idはoptionsから取得する
      aud: options['client_id'],
      # aud: ENV['CLIENT_ID_OF_MY_OP'],
      # ...

 

config/initializer/omniauth.rb の修正

同じストラテジーを使うため、同じストラテジーを複数回使えるよう nameintrospection を渡します。

また、scope introspection も追加します。

Rails.application.config.middleware.use OmniAuth::Builder do
  # 前回のストラテジー
  provider :my_op,
           ENV['CLIENT_ID_OF_MY_OP'],
           ENV['CLIENT_SECRET_OF_MY_OP']

  # 今回追加したストラテジー
  provider :my_op,
           ENV['CLIENT_ID_OF_INTROSPECTION'],
           ENV['CLIENT_SECRET_OF_INTROSPECTION'],
           name: 'introspection',
           scope: 'openid introspection'
end

 
ここまででOmniAuthまわりの修正が終わりました。

 

新しいOAuth Application 用の実装を追加

前回作成した sessions_controller とは別のControllerを作成して、今回のOAuth Applicationとします。

 

Controllerを生成

% bin/rails g controller Introspections index 
Running via Spring preloader in process 55835
      create  app/controllers/introspections_controller.rb
       route  get 'introspections/index'
      invoke  erb
      create    app/views/introspections
      create    app/views/introspections/index.html.erb
      invoke  helper
      create    app/helpers/introspections_helper.rb
      invoke  assets
      invoke    css
      create      app/assets/stylesheets/introspections.css

 

Viewの修正

ASへリクエストを飛ばせるよう、Viewを修正します。

なお、先ほど作成した OmniAuthのProvider introspection で処理できるよう、 form_tag のURLには /auth/introspection と指定します。

<h1>Introspections#index</h1>

<% if flash[:notice] %>
  <p>
    <%= flash[:notice] %>
  </p>
<% end %>

<%= form_tag('/auth/introspection', method: 'post') do %>
  <button type='submit'>Login</button>
<% end %>

 

routes.rb の修正

前回のroutes.rb ではAS からのコールバックURI

get 'auth/:provider/callback', to: 'sessions#create'

となっています。

このままではすべてSessions Controllerの create メソッドで処理されてしまいます。

そこで今回追加するOAuthApplicationの introspection は優先して処理されるよう、前回の定義よりも上に追加します。

 
なお、今回追加するものは

  • index
    • Viewを表示するための
  • callback
    • ASからのコールバックURI

の2つです。

Rails.application.routes.draw do
  # ルート設定
  root to: 'home#index'

  # 下のOPからのコールバックURIにマッチする前にマッチしたいのでここで設定
  # introspection_rp のコールバック先
  get 'auth/introspection/callback', to: 'introspections#callback'
  get 'introspection', to: 'introspections#index'

  # OPからのコールバックURI
  get 'auth/:provider/callback', to: 'sessions#create'
  # ...
end

 

Controllerの修正

今回のControllerでは、アクセストークンの状態

  • ASから渡された値そのまま
  • 不正なアクセストークンに差し替え
  • 有効期限切れ
  • Revocationエンドポイントで取り消し済

ごとに、RS のAPIへリクエストしレスポンスを受け取ります。

 
そこで、

  • RSのAPIへGETする
    • このAPIの中で、RSからASの Introspection エンドポイントへのリクエストが発生する
  • ASのRevocationエンドポイントへPOSTする

の2つのメソッドを用意します。

また、今回はいずれもFaradayを使ってみます。
Usage | Faraday

 

RSのAPIへGETするメソッドを追加

今回、RSのAPIにアクセスするにはASから渡された access_token が必要だとします。

Auth屋さんの本「【電子版】雰囲気でOAuth2.0を使っているエンジニアがOAuth2.0を整理して、手を動かしながら学べる本 - Auth屋 - BOOTH」より、Authorization ヘッダに Bearer + access_token をセットすることで、RSのAPIaccess_tokenを渡せます。

def fetch_resource_server(access_token)
  headers = { Authorization: "Bearer #{access_token}" }
  response = Faraday.get('http://localhost:3782/apples/show', {}, headers)
  response.tap do |r|
    puts '======> API'
    puts "STATUS: #{r.status}"
    puts "BODY  : #{r.body}"
    puts '<====== API'
  end
end

 

ASのRevocationエンドポイントへPOSTするメソッドを追加

アクセストークンやリフレッシュトークンを取り消すために、ASのRevocationエンドポイントへPOSTするメソッドを追加します。

doorkeeperのWikiによると

  • クライアントID
  • クライアントシークレット
  • アクセストーク

をPOSTすることで、アクセストークンやリフレッシュトークンを取り消せます。
POST /oauth/revoke | API endpoint descriptions and examples · doorkeeper-gem/doorkeeper Wiki

def revoke_tokens(access_token)
  params = {
    client_id: ENV['CLIENT_ID_OF_INTROSPECTION'],
    client_secret: ENV['CLIENT_SECRET_OF_INTROSPECTION'],
    token: access_token
  }
  response = Faraday.post("#{ENV['OIDC_PROVIDER_HOST']}/oauth/revoke", params)
  response.tap do |r|
    puts '======> introspection'
    puts r.headers
    puts '---------------------'
    puts r.body
    puts '<====== introspection'
  end
end

 

コールバックメソッドの作成

各エンドポイントへリクエストするメソッドができました。

最後に、前述の

  • ASから渡された値そのまま
  • 不正なアクセストークンに差し替え
  • 有効期限切れ
  • Revocationエンドポイントで取り消し済

のアクセストークンの状態ごとに動作を確認できるよう、ASからのコールバック用メソッドを作成します。

なお、動作確認するときには

  • 有効期限切れ
  • Revocationエンドポイントで取り消し済

を両立させるのは難しいため、コメントアウトするなどして片方ずつ確認します。

class IntrospectionsController < ApplicationController
  def index
  end

def callback
  auth_hash = request.env['omniauth.auth']
  access_token = auth_hash['credentials']['token']

  puts '================> CORRECT access_token'
  # Authorization Serverから受け取った access_token を使って、Resource Serverへリクエスト
  fetch_resource_server(access_token)

  # 不正な access_token を使って、Resource Serverへリクエスト
  puts '================> INCORRECT access_token'
  incorrect_access_token = "#{access_token}_bad"
  fetch_resource_server(incorrect_access_token)

  # 有効期限切れの access_token を使って、Resource Serverへリクエスト
  puts '================> EXPIRED access_token'
  sleep 70
  fetch_resource_server(access_token)

  # Clientで access_token を revoke 後に、Resource Serverへリクエスト
  puts '================> REVOKE access_token'
  revoke_tokens(access_token)
  fetch_resource_server(access_token)

  redirect_to introspection_path, notice: access_token
end
# ...

 

Resource Server (RS) の追加

Resource Server を

  • Clientからリクエストを受け付けるAPIを作成
  • APIの実行前に、ASのIntrospection APIaccess_token の検証を行う
    • 成功した場合、APIの結果を返す
    • 失敗した場合、HTTP 401

の仕様で実装します。

 

rails new

今回はAPIとして作成します。

% bundle exec rails new rails_resource_server --api

 

.env ファイルを追加

AS の情報を持つ .env ファイルを用意します。

OIDC_PROVIDER_HOST=http://localhost:3780
CLIENT_ID_OF_RESOURCE_SERVER=
CLIENT_SECRET_OF_RESOURCE_SERVER=

 

コントローラを作成

生成します。

% bin/rails g controller apples show
Running via Spring preloader in process 15084
      create  app/controllers/apples_controller.rb
       route  get 'apples/show'
      invoke  test_unit
      create    test/controllers/apples_controller_test.rb

 
コントローラでは

  • showメソッドで、APIへのリクエストに対しレスポンスする
  • showメソッドの before_action で、ASの Introspection API による access_token 検証を行う

を実装します。

後者については、以降で詳しくみていきます。

 

AS の Introspection API による access_token の検証

doorkeeperのWikiによると、Introspection APIを使うには

Post here with client credentials (in basic auth or in params client_id and client_secret) or with Bearer token to introspect an access/refresh token. This corresponds to the token endpoint, using the RFC7662 - OAuth 2.0 Token Introspection.

POST /oauth/introspect | API endpoint descriptions and examples · doorkeeper-gem/doorkeeper Wiki

とありました。

また、以下の記事には Authorization ヘッダにて Basic認証やBearerトークンを使っている例が記載されています。
RFC7662として発行されたOAuth Token Introspectionとは - r-weblife

 
そこで今回は Bearer token を使うこととし、OAuth2.0 のクライアントクレデンシャルフローにより、ASから access_token を取得します。

また、

  • Authorization ヘッダに、クライアントクレデンシャルフローで取得した access_token を Bearer token として設定
  • リクエストボディに、検証対象の access_token を設定

を行い、ASのIntrospection APIを呼び出すようにします。

 
RSのコントローラの全体像はこんな感じです。

class ApplesController < ApplicationController
  before_action :validate_bearer_token
  def show
    render json: { name: 'シナノゴールド' }
  end

  private

  def validate_bearer_token
    # Bearer トークンを取得
    authorization_header = request.headers['Authorization']
    return render status: 401 if authorization_header.blank?

    access_token = authorization_header.gsub('Bearer ', '')
    return render status: 401 if access_token.blank?

    # クライアントクレデンシャルフローで、Resource Serverのアクセストークンを取得する
    client = OAuth2::Client.new(ENV['CLIENT_ID_OF_RESOURCE_SERVER'],
                                ENV['CLIENT_SECRET_OF_RESOURCE_SERVER'],
                                site: ENV['OIDC_PROVIDER_HOST'])
    oauth2_response = client.client_credentials.get_token(scope: 'introspection')

    # Faradayを使って、Introspectionエンドポイントで access_token を検証
    headers = { Authorization: "Bearer #{oauth2_response.token}" }
    params = { token: access_token }
    response = Faraday.post("#{ENV['OIDC_PROVIDER_HOST']}/oauth/introspect", params, headers)

    response.tap do |r|
      puts '======> introspection'
      puts "STATUS: #{r.status}"
      puts "BODY  : #{r.body}"
      puts '<====== introspection'
    end

    body = JSON.parse(response.body)
    render status: 401 if response.status == 401 || body['active'] == false
  end
end

 

pumaの起動ポートを修正

ASやClient同様、pumaの起動ポートを修正します。

config/puma.rb

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

 
以上でRSの実装も終わりました。

 

動作確認

OAuth Applicationを準備

http://localhost:3780/oauth/applications にアクセスし、OAuthApplicationを登録します。

今回はClientとRSの2つを追加します。

項目 Client用 RS用
Name introspection_rp resource_server
Callback URL http://localhost:3781/auth/introspection/callback http://localhost:3782
(本来なら不要だが、空欄だとエラーになるため)
Confidential? Yes Yes
Scopes openid introspection introspection

 
登録後に表示されるクライアントID・クライアントシークレットは、ClientやRSの .env ファイルの

  • CLIENT_ID_OF_INTROSPECTION
  • CLIENT_SECRET_OF_INTROSPECTION

などの値へ設定します。

 

実行

今回は

  1. http://localhost:3781/introspection にアクセスし、 Login ボタンをクリック
  2. ASでログイン
  3. ASの認可画面で許可
  4. Clientに戻ってきて、access_token が表示

の順による動作確認をします。

なお、再掲となりますが、

  • 有効期限切れ
  • revoke済

は両立しないため、コメントアウトするなどして片方ずつ試します。

 

ASから渡された値そのままのアクセストークンの場合

ClientのログにはRSのAPI呼び出しが成功し、APIから値が返ってきていることが分かりました。

================> CORRECT access_token
======> API
STATUS: 200
BODY  : {"name":"シナノゴールド"}
<====== API

 
また、RSのログを見ると、 access_token の Introspection が成功していました。

Started GET "/apples/show" for 127.0.0.1 at 2021-08-25 22:57:53 +0900
Processing by ApplesController#show as */*
======> introspection
STATUS: 200
BODY  : {"active":true,"scope":"openid introspection","client_id":"CLIENT_ID","token_type":"Bearer","exp":1629899933,"iat":1629899873}
<====== introspection

 

不正なアクセストークンに差し替えた場合

ClientのログにはRSのAPI呼び出しが失敗し、HTTP 401 が返ってきていることが分かりました。

================> INCORRECT access_token
======> API
STATUS: 401
BODY  :  
<====== API

 
また、RSのログを見ると、Introspectionエンドポイントから "active":false が返っていました。

======> introspection
STATUS: 200
BODY  : {"active":false}
<====== introspection

 

有効期限切れのアクセストークンの場合

ClientのログにはRSのAPI呼び出しが失敗し、HTTP 401 が返ってきていることが分かりました。

================> EXPIRED access_token
======> API
STATUS: 401
BODY  :  
<====== API

 
また、RSのログを見ると、Introspectionエンドポイントから "active":false が返っていました。

======> introspection
STATUS: 200
BODY  : {"active":false}
<====== introspection

 

Revocationエンドポイントで取り消し済のアクセストークンの場合

Clientのログより、

  • token Revocationエンドポイントから、HTTP 200が返る
  • RSのAPI呼び出しが失敗

と分かりました。

================> REVOKE access_token
======> revocation
STATUS: 200
BODY  : {}
<====== revocation
======> API
STATUS: 401
BODY  :  
<====== API

 
また、RSのログを見ると、Introspectionエンドポイントから "active":false が返っていました。

======> introspection
STATUS: 200
BODY  : {"active":false}
<====== introspection

 

ソースコード

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

 
今回の実装分ですが、最初に公開した時のPRはこちらです。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample/pull/2

また、ご指摘を受けて修正した時のPRはこちらです。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample/pull/3

ヒドいぎっくり腰になったので、経過を記録してみた

最近、ヒドいぎっくり腰(急性腰痛症)になりました。

 
過去に何回かぎっくり腰をやった時は、しばらく安静にしていることで、いずれもその日のうちに動けるようになっていました。

それに比べて今回のはヒドく、人生初の救急車や入院を経験し、各方面にご迷惑をおかけしました。

そこで、

  • もうぎっくり腰になりたくないので、筋トレやストレッチをがんばりたい
    • 忘れた頃にこのエントリーを読み返して、モチベーションを維持する
  • 万が一、今後も発生したときに「どれくらいで治るか」の目安にする

を目的とした、症状の経過に関するメモを残します。

 
目次

 

初日

  • 朝シャワーを浴びた後、ふと気づいたら腰に力が入らなくなり、パンイチで倒れ込んだ
    • 発症した時の記憶があいまい。。。
    • 動こうとすると、腰に電撃が走るような痛みが出る
  • 30分くらいたっても変わらないため、寝室へ移動
    • 腰が立たず二足歩行できないことから、移動方法はハイハイ
    • 道中、冷蔵庫から保冷剤を取り出し、服でくるんで腰に乗せた
    • 換気のために部屋の窓を開けていたが、腰が立たないため、窓を閉めたりエアコンを入れたりできず
  • こんなこともあろうかと、手の届く高さに保管しておいたコルセットを枕元に置いた
  • 会社のデイリースクラムの時間になっても動けないことから、Slackで休暇連絡
    • この時点では「ぎっくり腰だろうけど、時間がたてば動けるようになるから整形外科へ行こう」と思っていた
  • 足の感覚はあり自由に動かせたが、腰だけが言うことをきかなかった
    • いつもどうやって腰を動かしていたのか思い出せず
  • うつ伏せや仰向けだと痛みが出るので、横向きで安静にしてたら、いつの間にか寝てた
  • 起きたらお昼の時間を過ぎていたが、食欲がなかったので、お昼ごはんはパスした
  • 時間がたつにつれ、痛みの出ない姿勢が減っていったため、姿勢を求めて寝返りを繰り返した
  • 西窓の部屋だったため、午後日差しが強くなり気温も上がってきた
    • 「このままでは直射日光を食らう & 熱中症でやられるのでは」と思ったが動けず
    • 結局曇りになったので直射日光は避けられたものの、水分補給ができないままなので熱中症の不安は残った
  • 整形外科の診察締め切り時間が近づいたので、コルセットをつけた後に起きようとしたが、激しい痛みで起き上がれず
    • コルセットをつけたほうが痛みが強かった
  • そのうち、こむら返りの腰版が発生するようになった
    • 発生すると、腰から全身へ痛みが広がる
    • 少しでも動くとこむら返りの腰版が発生し、大声を出しながら転げ回ってた
  • 時間の経過とともにこむら返りの腰版が頻発するようになり、「さすがにやばいのでは」と思い始めた
  • 晩ごはんの食欲もわかず
  • 夜になっても腰が悪化し続けたので、覚悟を決めた
    • 会社にはSlackで「翌日も休みます」連絡
    • 翌日の私用もキャンセル
  • 必要最低限の荷物を用意して通報
    • 会社スマホ & 自分スマホ & モバイルバッテリー & 保険証 & 財布 & 1日分の着替え
    • 保冷剤をくるんでた服が何とか回収・着用できたため、パンイチでの救急車は避けられた
  • ありがたいことに、救急車はすぐ到着
    • 到着しても、こむら返りの腰版で転げ回る
  • 担架で搬送
  • 救急で受け入れてもらえることになり、ホッとした
  • 救急車の中を色々見ることができるくらいには、心の余裕が出てきていた
  • 病院に到着直後、検査するには痛みが強すぎたらしく、坐薬が投入された
    • あとで聞いたところ、一番強いものが投入されたらしい
  • しばらく安静にしていても状態があまり変わらず
    • 坐薬が効いてる時間になったけれど、姿勢によっては腰に電撃が走る
      • こむら返りの腰版が発生しないくらい
    • ただ、検査しないと何も始まらないので、ゴロゴロ回転してストレッチャーに移動してCT検査へ
    • 初めてのCTにはワクワクした
  • CTの結果、骨はキレイだったらしく、ぎっくり腰と診断された
  • とはいえ動けないので、入院することに
    • 帰宅しても生活できないので、心の底から感謝した
  • ストレッチャーで病室へ移動
    • 完全に坐薬が効いている時間だが、まだ姿勢によっては腰に電撃が走る状態だった
    • 複数の看護師さんの力をお借りして、シーツごとベッドへ移動
  • 坐薬で眠れるだろうと思ったが、意外と寝付けず
    • 寝ても痛みで起きた

 

2日目

  • 朝起きても、腰が立たない、腰が動かない
    • 上半身と下半身は問題なく動くので、それをつなげる部分だけが動かないという不思議な感覚
    • この腰が本当に治るのか、そして退院できるのかと不安になる
  • 朝ごはんが届く
    • 食欲は戻っていた
    • ベッドを起こした時に腕が届く範囲にごはんを配膳してもらい、ベッドに体を支えてもらいながら腕とフォークだけを使って食べた
    • 24時間ぶりに水分補給できた
  • 退院の見込みがたたないので、会社にその旨を連絡
  • 会社への連絡後、薬がきいたのか、お昼までよく寝た
  • お昼ごはんも、ベッドを起こし、腕とフォークだけで何とか食べた
    • 食後に坐薬を入れ、また寝た
  • 夕方、さすがにトイレに行きたくなり、手押しの点滴台とクロックスを借りて、手すりを伝って移動することに
    • 担架で運ばれた救急で靴を持ってきていなかったので、クロックスを借りられて本当にありがたかった
    • 腰は立たないままだが、腕と手すりを頼りに何とかトイレに行け、ホッとした
    • また、「今後この状態と比較することで、回復具合を把握できそう」と思った
  • 晩ごはんの時に、横向きでの上体起こしを試みたが、痛みが出たため失敗に終わった
    • 朝昼同様、ベッドを起こして腕とフォークだけで食べた
  • 晩ごはんから消灯時間までの間に、スマホブラウジングしたところ姿勢が固定されたようで、腰に痛みが出た
    • なお、スマホのバッテリーにも余裕がなく、その後ブラウジングする機会はなかった
      • 持ち物として充電器を忘れるという痛恨のミス
      • 売店でも売ってないとのこと
  • 結局ゴロゴロ回転して時間を過ごし、消灯時間になったら眠った
  • 夜中、腰に痛みが出るたびに目覚めた
    • 腰を動かせられないため寝返りが打てず、ずっと同じ姿勢だったのが原因っぽい

 

3日目

  • 朝、腰の重い感じが抜けていない
  • 朝ごはんを食べる時に、横向きでの上体起こしを試したところ、だいぶ上がるようになった
    • 手すりにつかまることで、ベッドを起こさなくても食べられるくらいになった
    • 「腰が言うことをきいてくれる」と嬉しくなった
  • 点滴台と一緒であれば、よちよち歩きでトイレに行けた
    • とはいえ、歩行時に腰が強くしめつけられているような痛みや圧迫感はあった
    • また、手すりがないと、ベッドから降りるのも難しい状態
  • この日も同じ姿勢でいると腰が固まるのか、痛みが発生してた
    • ベッドの上でゴロゴロしながら過ごす
  • まだ自宅で生活できそうにないので、明日退院を目標にした
  • 夜眠れないと困るので、坐薬を入れてもらって就寝
    • それでも、長い時間姿勢が固定されたときは目覚めた

 

4日目

  • 朝起きたら、だいぶ体が軽い
  • 横向きでの上体起こしができるようになり、ベッドに腰掛けられるようになった
    • 朝ごはんから、ベッドに腰掛けて食べることができた
  • かがむと痛みが出る
    • 前傾・後傾も無理
    • まっすぐ立つ分には問題ない
  • 点滴台なしでも、よちよち歩きができるようになった
    • 腰に圧迫感はあったが、昨日よりは確実に改善していた
  • この状態であれば、家でも生活できそうと判断
    • スマホのバッテリーがギリギリだった、ということもあり
  • 退院
    • お世話になりました、ありがとうございました
  • 久しぶりの外の世界
    • 病院内には無かった段差があったものの、何とか乗り越えた
  • 無事に帰宅
    • 病院内に比べて長い距離を歩いたせいか、股関節と尻が筋肉痛っぽくなった
    • 久しぶりにシャワーを浴びた
  • 湿布を貼ったり薬を飲んだり
  • 基本は横になってた

 

5日目

  • 起床直後が一番腰に来ている
    • 日中動くとだいぶ良くなる
  • 上体起こしまでは問題なくできるようになった
    • 机につかまり、立ち上がることもできた
    • ただし、痛みは出る
  • 腰に電撃が走るような痛みは消えた
  • よちよち歩き
  • 湿布を貼ったり薬を飲んだり
  • 基本は横になってた

 

6日目

  • 起床直後が一番腰に来ている
    • 日中動くとだいぶ良くなる
  • 机を使ったつかまり立ちもスムーズになった
    • 腰の違和感はある
  • 歩く歩幅が広がった
  • 腰に変な力が入ると、筋肉の張るような痛みが出る
  • 湿布を貼ったり薬を飲んだり

 

7日目

  • 起床直後が一番腰に来ている
    • 日中動くとだいぶ良くなる
  • 立ち上がりもスムーズに
  • 歩き方もスムーズに
  • 斜め前のものを取ろうとすると、腰が張るような痛みが出る
  • 湿布を貼ったり薬を飲んだり

 

8日目〜

ここからは緩やかな回復だったため、まとめて書きます。

もし、何か気づいたことがあれば、このメモに追記します。

  • 日を追うごとに回復
  • 起床直後が一番腰に来ている
    • 日中動くとだいぶ良くなる
  • 電撃が走るような痛みに代わり、腰への圧迫を感じる範囲が広がった
    • 湿布を増やして対処すればおさまる
  • かがむと、腰の張りを感じる
  • 寝起きは問題なくできる
  • 歩行はスムーズにできる
  • ジャンプやダッシュはこわい
  • くしゃみするときも、腰に反動を感じる
  • 食洗機や洗濯機、料理くらいはなんとかなる
    • シンクに置いた洗い物を取ろうとすると腰に張りが出るので、食洗機さまさま
  • 重いものを持とうとすると腰に不安が出る
    • 物の正面に立ち、腰を落として持てば、何とか持てる
    • とはいえ、重い物を持つことへの恐怖感はまだある
      • 気持ちとしては、鈍器な技術書が限界
  • スタンディングデスクメインであれば、macでタイピングするのは問題ない
    • 疲れたら、座ったり歩き回る
  • 腹筋は問題なくできる
    • 背筋のストレッチをやろうとすると、腰に不安が出る
  • 薬が終わり、湿布だけになった
    • 痛みが出たら市販薬

 
以降は、気づいた時に追記している内容です。

 

14日目

  • 自転車へ乗れるようになった
    • ギアは一番軽いけど
  • 足の甲や足首のむくみがひどい
    • 血管や骨が見えないほど

 

15日目

  • 30分くらい、ゆっくりしたペースで連続して歩けるようになった

 

16日目

  • おしりや股関節が筋肉痛のような痛みが出た
    • 最近運動不足だったので、歩いた影響かもしれない
  • 腰の不安なく、階段の上り下りができるようになった
  • 前屈するとまだ痛い

 

20日

  • 一日座って仕事しても大丈夫になった
  • 座って仕事する時間が増えたせいか、足のむくみは取れた
  • 浅い前屈であれば痛みは出なくなった
  • 立ってズボンをはけるようになった
  • バックパックに重い物を背負った時、腰に不安感はある

 

27日目

  • 片道40分くらい自動車を運転したら、翌日腰に違和感が出た

 

終わりに

現在変な姿勢だと腰に違和感が出るものの、普通に生活できるようになりました。初日の状態から考えると、本当に奇跡としか言いようがないです。

 
最後になりましたが、公私とも色々な方々にご迷惑をおかけしました。ありがとうございました。

今後ぎっくり腰にならないよう、日々体のメンテナンスを続けていこうと思います。

Railsとdoorkeeper-openid_connectやOmniAuth を使って、OpenID Connectの OpenID Provider と Relying Party を作ってみた

OAuth2やOpenID Connectの理解を深めようと思い、

などを読みました。

読んでいくうちに、OpenID Connectの OpenID Provider (以降、OP) と Relying Party(以降、RP)の両方を実装し、動作を確認してみたくなりました。

そこで、最近使ってるRuby + Railsにて、OPとRPを実装してみたときのメモを残します。

なお、今回は動作を確認するのが主な目的なため、OPやRPをイチから作るのではなく、gemを組み合わせて作ることとします。

 
目次

 

環境

gemなどのバージョン

  • macOS
  • Open ID Provider
    • localhost:3780 で起動
    • Rails 6.1.4
    • doorkeeper 5.5.2
    • doorkeeper-openid_connect 1.8.0
    • devise 4.8.0
  • Relying Party
    • localhost:3781 で起動
    • Rails 6.1.4
    • omniauth 2.0.4
    • omniauth-oauth2 1.7.1
    • activerecord-session_store 2.0.0
      • セッションストアをActive Record (DB) へと変更

 

実装すること

  • OpenID ConnectのOPとRPをRuby + gem で実装
  • OpenID Connectのフローは 認可コードフロー のみ実装
  • IDトークンの検証で使うOPの公開鍵は、OPの公開鍵エンドポイントから動的に取得する
  • セキュリティ対策
    • statePKCEnonce
      • 上記は、使用するgemで実装済かどうかも調べる

 
また、OpenID Connect フローの概要を OpenID Connect Core 1.0 より引用します。

+--------+                                   +--------+
|        |                                   |        |
|        |---------(1) AuthN Request-------->|        |
|        |                                   |        |
|        |  +--------+                       |        |
|        |  |        |                       |        |
|        |  |  End-  |<--(2) AuthN & AuthZ-->|        |
|        |  |  User  |                       |        |
|   RP   |  |        |                       |   OP   |
|        |  +--------+                       |        |
|        |                                   |        |
|        |<--------(3) AuthN Response--------|        |
|        |                                   |        |
|        |---------(4) UserInfo Request----->|        |
|        |                                   |        |
|        |<--------(5) UserInfo Response-----|        |
|        |                                   |        |
+--------+                                   +--------+

 

実装しないこと

 

Open ID Provider (OP)の作成

まずは、Open ID Provider から作成します。

 

OP実装で使用するgemについて

以下のスライドより、Rails + doorkeeper + doorkeeper-openid_connect で作ります。
わかった気になる!OpenID Connect - Speaker Deck

また、ユーザー登録や認証を容易にするため、devise も使います。

 

各種インストール

rails new します。

% bundle exec rails new rails_open_id_provider --skip-javascript --skip-turbolinks --skip-sprockets --skip-test

 
Gemfileに

を追加します。

# 追加
gem 'devise'
gem 'doorkeeper'
gem 'doorkeeper-openid_connect'

group :development do
  gem 'annotate'
end

 
bundle install します。

% bundle install
...
Post-install message from doorkeeper:
Starting from 5.5.0 RC1 Doorkeeper requires client authentication for Resource Owner Password Grant
as stated in the OAuth RFC. You have to create a new OAuth client (Doorkeeper::Application) if you didn't
have it before and use client credentials in HTTP Basic auth if you previously used this grant flow without
client authentication.

To opt out of this you could set the "skip_client_authentication_for_password_grant" configuration option
to "true", but note that this is in violation of the OAuth spec and represents a security risk.

Read https://github.com/doorkeeper-gem/doorkeeper/issues/561#issuecomment-612857163 for more details.

 
READMEに従い、 annotate をセットアップします。
https://github.com/ctran/annotate_models#configuration-in-rails

% bin/rails g annotate:install
Running via Spring preloader in process 8056
      create  lib/tasks/auto_annotate_models.rake

 

起動ポートのデフォルト値を修正

後ほど RP の Railsアプリも作成するため、デフォルトのままでは起動ポート 3000 が重複してしまいます。

そこで、以下に従い config/puma.rb を修正します。
rails s 時のデフォルトのポート番号を変更する - Qiita

OPは 3780 ポートにしてみます。

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

 

deviseのセットアップ

installします。

% bin/rails g devise:install
Running via Spring preloader in process 7473
      create  config/initializers/devise.rb
      create  config/locales/devise.en.yml

 
OPで管理するUserのモデルを作成します。

% bin/rails g devise user   
Running via Spring preloader in process 7543
      invoke  active_record
      create    db/migrate/20210812140841_devise_create_users.rb
      create    app/models/user.rb
      insert    app/models/user.rb
       route  devise_for :users

 
今回使うdeviseの機能は

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

だけになります。

そこで、マイグレーションファイル db/migrate/<TIMESTAMP>_devise_create_users.rb を修正し、database authenticatable だけ有効にします。

class DeviseCreateUsers < ActiveRecord::Migration[6.1]
  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) のdeviseモジュールも修正しておきます。

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

 

doorkeeperのセットアップ

devise のセットアップが終わったため、次は doorkeeper のセットアップを行います。

 

初期化

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

 
まずは install します。

% bin/rails generate doorkeeper:install
Running via Spring preloader in process 8136
      create  config/initializers/doorkeeper.rb
      create  config/locales/doorkeeper.en.yml
       route  use_doorkeeper
===============================================================================

There is a setup that you need to do before you can use doorkeeper.

Step 1.
Go to config/initializers/doorkeeper.rb and configure
resource_owner_authenticator block.

Step 2.
Choose the ORM:

If you want to use ActiveRecord run:

  rails generate doorkeeper:migration

And run

  rake db:migrate

Step 3.
That's it, that's all. Enjoy!

===============================================================================

 
続いて、マイグレーションファイルを生成します。

% bin/rails generate doorkeeper:migration
Running via Spring preloader in process 8229
      create  db/migrate/20210812141653_create_doorkeeper_tables.rb

 
devise の User モデルに合わせるため、マイグレーションファイルの以下の部分を修正します。

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

 

設定変更

doorkeeper の設定を行うため、 config/initializer/doorkeeper.rb ファイルを修正します。

今回は以下の設定を行います。

  • resource_owner_authenticator
  • admin_authenticator
    • 今回はユーザーが全てのOAuth Applicationの操作可能とするため、doorkeeperのドキュメントに従って設定
    • Configuration - doorkeeper
  • default_scopes
    • OpenID Connect を使うため、 openid

 
なお、 skip_authorization の設定で true を返すようにすれば、「同意画面を表示せず、自動で同意したことにする」ことも可能です。
https://github.com/doorkeeper-gem/doorkeeper/blob/v5.5.2/lib/generators/doorkeeper/templates/initializer.rb#L426

ただし、RPの認証リクエストのパラメータで prompt が指定された場合は、 prompt の値に従った動作となります。
21. プロンプト (prompt) | OAuth & OpenID Connect 関連仕様まとめ - Qiita

prompt パラメータはこのPRあたりで有効になったようです。
Support other prompt parameters by toupeira · Pull Request #20 · doorkeeper-gem/doorkeeper-openid_connect

 
他のdoorkeeperの設定については、ソースコードのコメントを読む他、Wikiやドキュメントなどに記載があります。

 
不要なコメントを削除した設定内容は以下の通りです。

# frozen_string_literal: true

Doorkeeper.configure do
  orm :active_record
  
  resource_owner_authenticator do
    current_user || warden.authenticate!(scope: :user)
  end

  admin_authenticator do
    current_user || warden.authenticate!(scope: :user)
  end

  default_scopes  :openid
end

 

doorkeeper-openid_connectのセットアップ

初期化

READMEに従い、セットアップします。
https://github.com/doorkeeper-gem/doorkeeper-openid_connect

 
installします。

% bin/rails g doorkeeper:openid_connect:install 
Running via Spring preloader in process 8849
      create  config/initializers/doorkeeper_openid_connect.rb
      create  config/locales/doorkeeper_openid_connect.en.yml
       route  use_doorkeeper_openid_connect

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

% bin/rails generate doorkeeper:openid_connect:migration
Running via Spring preloader in process 8917
      create  db/migrate/20210812142144_create_doorkeeper_openid_connect_tables.rb

 

IDトークンの署名で使う鍵を生成

IDトークンの署名で使うための鍵を生成します。

今回は公開鍵方式にするため、以下を参考に RS256 の鍵を生成します。

# rails_open_id_provider ディレクトリの直下で実行
% ssh-keygen -t rsa -P "" -b 4096 -m PEM -f jwtRS256.key
Generating public/private rsa key pair.
Your identification has been saved in jwtRS256.key.
Your public key has been saved in jwtRS256.key.pub.

なお、誤ってcommitしないよう、生成した鍵は .gitignore に追加しておきます。

 

設定変更

doorkeeper-openid_connect の設定を行うため、 config/initializer/doorkeeper_openid_connect.rb ファイルを修正します。

今回は以下の設定を行います。

  • issuer
    • OPの稼働するホスト名 ( http://localhost:3780 )
  • signing_key
  • claims
    • UserInfoエンドポイントで返す claims に email を追加
  • 以下の項目はコメントアウトされているものをアンコメント
    • resource_owner_from_access_token
    • reauthenticate_resource_owner
    • subject

 
不要なコメントを削除した設定内容は以下の通りです。

# frozen_string_literal: true

Doorkeeper::OpenidConnect.configure do
  issuer 'http://localhost:3780'

  signing_key File.read(Rails.root.join('jwtRS256.key'))

  subject_types_supported [:public]

  resource_owner_from_access_token do |access_token|
    User.find_by(id: access_token.resource_owner_id)
  end

  auth_time_from_resource_owner do |resource_owner|
  end

  reauthenticate_resource_owner do |resource_owner, return_to|
    store_location_for resource_owner, return_to
    sign_out resource_owner
    redirect_to new_user_session_url
  end

  select_account_for_resource_owner do |resource_owner, return_to|
  end

  subject do |resource_owner, _application|
    resource_owner.id
  end

  claims do
    # normal_claimはclaimのalias
    normal_claim :email, scope: :openid do |resource_owner|
      resource_owner.email
    end
  end
end

 

routeの確認

doorkeeper-openid_connect により、OpenID Connect 向けのrouteが追加されています。

% bin/rails routes
Verb   URI Pattern                                  Controller#Action
GET    /oauth/userinfo(.:format)                    doorkeeper/openid_connect/userinfo#show
POST   /oauth/userinfo(.:format)                    doorkeeper/openid_connect/userinfo#show
GET    /oauth/discovery/keys(.:format)              doorkeeper/openid_connect/discovery#keys
GET    /.well-known/openid-configuration(.:format)  doorkeeper/openid_connect/discovery#provider
GET    /.well-known/webfinger(.:format)             doorkeeper/openid_connect/discovery#webfinger
...

 

マイグレーション

セキュリティまわりを除き一通りの実装が終わったため、マイグレーションを実行しておきます。

% bin/rake db:migrate
...
Annotated (1): app/models/user.rb

 

Relying Party (RP) の作成

続いて、Relying Party (以下 RP) を作成します。

 

実装方針

今回のRPの機能は

  • OPのユーザー情報を元にログイン
    • RPにユーザーがいない場合は、RPのユーザーを作成
  • ログアウト

だけです。

そのため、deviseを使わずに、ログイン/ログアウトを実装してみます。

なお、OpenID Connect部分については OmniAuth を使います。
omniauth/omniauth: OmniAuth is a flexible authentication system utilizing Rack middleware.

ちなみに、OmniAuthの2系を使うため、公式Wikiにて注意点などを確認します。
Upgrading to 2.0 · omniauth/omniauth Wiki

 
その他のgemとして、

  • デバッグ時にセッションの中身を見やすくするため、セッションをActive Recordに保存
    • activerecord-session_store
  • OPから払い出されたクライアントIDとクライアントシークレットをハードコーディングするのではなく、 .env ファイルから読み込む
    • dotenv-rails

も使います。

 

OmniAuthのストラテジーについて

OmniAuthのストラテジーの一覧は、公式のWikiに記載されています。
List of Strategies · omniauth/omniauth Wiki

しかし、OpenID Connectのストラテジーは一覧に見当たりませんでした。

 
調べてみたところ、以下のgemがありました。
https://github.com/m0n9oose/omniauth_openid_connect

ただ、OmniAuthが1.9系でした。2系に対応するPRはありましたが、取り込まれていませんでした。
https://github.com/m0n9oose/omniauth_openid_connect/pull/84

Gitlabでもこのgemを使ってるようでしたが、色々あってGitlab側でforkしたようです。
https://gitlab.com/gitlab-org/gitlab/-/issues/225850

fork先を見てみましたが、色々修正が入っているものの、OmniAuthは1.9系のままでした。
https://gitlab.com/gitlab-org/gitlab-omniauth-openid-connect

 
そこで、以下の記事やソースコードを参考にしながら、OAuth2のストラテジーOpenID Connectの機能を追加したものを自作します。

 

各種インストール

rails new します。

% bundle exec rails new rails_relying_party_of_backend --skip-javascript --skip-turbolinks --skip-sprockets --skip-test

 
Gemfileに以下を追加して bundle install します。

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

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

# デバッグ時にセッションの中身を見やすくするよう、セッションを ActiveRecord で管理するために追加
gem 'activerecord-session_store'

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

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

 

Active Record によるセッションストアの設定

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

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

% bin/rails generate active_record:session_migration
Running via Spring preloader in process 99042
      create  db/migrate/20210813010744_add_sessions_table.rb

 
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

 

起動ポートのデフォルト値を修正

OPと同じくRailsを使うため、RPは 3781 ポートで起動するよう config/puma.rb を修正します。

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

 

OmniAuthの設定

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

providerには

  • 第1引数
    • あとで作成するストラテジーの名称を指定
  • 第2引数
    • OPより払い出された、クライアントID
      • 今回は .env ファイルに記載し、 dotenv-rails で読み込む
  • 第3引数
    • OPより払い出された、クライアントシークレット
      • こちらも今回は .env ファイルに記載し、 dotenv-rails で読み込む

を指定します。

なお、ストラテジーで設定されている内容を上書きしたい場合は、以下のAuth0のストラテジーのように、引数としてハッシュを渡せば良いようです。
https://github.com/auth0/omniauth-auth0#additional-authentication-parameters

require 'omniauth/strategies/my_op'

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :my_op,
           ENV['CLIENT_ID_OF_MY_OP'],
           ENV['CLIENT_SECRET_OF_MY_OP']

  # ストラテジーの設定内容を上書きする場合は、以下のように追加しても良い
  # 参考: https://github.com/auth0/omniauth-auth0#additional-authentication-parameters
           # {
           #   authorize_params: {
           #     scope: 'openid profile',
           #     prompt: 'none'
           #   }
           # }
end

 

OmniAuthのストラテジーを作成

OmniAuthの設定で追加した my_op 用のストラテジーを作成します。

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

 

認証リクエストのパラメータを定義

まずは、認証リクエストのパラメータとして

  • name
    • ストラテジーの名前なので、 my_op
  • client_options
    • siteで、認証リクエストのエンドポイントを指定
  • scope
    • OpenID Connectのscopeなので、 openid

を設定します。

require 'omniauth-oauth2'

module OmniAuth
  module Strategies
    class MyOp < OmniAuth::Strategies::OAuth2
      # Give your strategy a name.
      option :name, 'my_op'

      # This is where you pass the options you would pass when
      # initializing your consumer from the OAuth gem.
      option :client_options, {
        site: "#{ENV['OIDC_PROVIDER_HOST']}/oauth/authorize"
      }

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

 

トークンエンドポイントから取得したIDトークンの検証とペイロードの取得

続いて、トークンエンドポイントから取得したIDトークンを検証する処理を実装します。

IDトークンは署名付JWT (JWS) のため、

  1. デコードしてIDトークンの中身を取得
  2. IDトークンを検証
  3. ペイロードを取得

の順で処理します。

 

Rubyで署名付きJWTの検証を行うgemについて

Rubyで署名付きJWTの検証を行うgemを探したところ、 ruby-jwt がありました。
jwt/ruby-jwt: A ruby implementation of the RFC 7519 OAuth JSON Web Token (JWT) standard.

また、Gemfile.lockを見ると

oauth2 (1.4.7)
...
  jwt (>= 1.0, < 3.0)
...
omniauth-oauth2 (1.7.1)
  oauth2 (~> 1.4)

と、 omniauth-oauth2 の依存ですでに jwt (ruby-jwt) がインストールされていました。

そこで、今回は ruby-jwt を使ってIDトークンの検証を行います。

 

IDトークン検証用メソッドの作成

まず、IDトークンの検証用メソッドを作成します。

引数として、

  • IDトークン (id_token)
  • IDトークンの sub の検証用に UserInfo エンドポイントから取得した subject の値 (subject_from_userinfo)

を用意します。

def id_token_payload(id_token, subject_from_userinfo)
  # ...
end

 

IDトークン検証の方向性 (JWT.decode)

続いて、

  1. IDトークンのデコード
  2. IDトークンの署名の検証
  3. IDトークンの中身の検証

を行います。

ruby-jwt では decode メソッドを使うことで一括で処理できるようです。

 
IDトークンの署名の検証では、OPの公開鍵を使います。

事前のOPの公開鍵をRPへ保存しておくことも考えられますが、今回はIDトークンの署名を検証するたびにOPの公開鍵エンドポイントから取得するようにします。

そのため、 decode メソッドを

  • 第2引数には nil を渡す
    • 固定の公開鍵を使う場合はここで渡せるが、今回は動的に公開鍵を取りに行くため、鍵は渡さない
  • encode メソッドのブロックとして、公開鍵を探しに行く処理を実装する

のように使います。

なお、公開鍵のアルゴリズムには OP で公開鍵を作成する時に使った

RSA using SHA-256 hash algorithm

である RS256 を指定します。

 
ここまでの decode メソッドの形は

JWT.decode(
  id_token, # JWT
  nil, # key: 署名鍵を動的に探すのでnil
  true, # verify: IDトークンの検証を行う
  { # options
    algorithm: 'RS256', # 署名は公開鍵方式なので、RS256を指定
  }
) do |jwt_header|
  # このブロックの中で、OPの公開鍵情報を取得
end

となります。

 

IDトークンの署名に使った、OPの公開鍵情報を取得

IDトークンの署名鍵として、OPの公開鍵情報を取得します。

OPの公開鍵エンドポイントは /oauth/discovery/keys です。

このエンドポイントアクセスするために、 oauth2 gemの依存としてインストール済の Faraday を使います。

 
なお、公開鍵エンドポイントのレスポンスはJSON形式です。また、公開鍵が1本だけだとしても、

{"keys"=>[
    { "kty"=>"RSA",
      "kid"=>"***",
      "e"=>"AQAB", 
      "n"=>"***", 
      "use"=>"sig", 
      "alg"=>"RS256"
    }
  ]
}

のように、キー keys の中に Array で入っています。

これらを踏まえた実装は以下の通りです。

def fetch_public_keys
  response = Faraday.get("#{ENV['OIDC_PROVIDER_HOST']}/oauth/discovery/keys")
  keys = JSON.parse(response.body)
  keys['keys']
end

 

IDトークンの署名の検証で使う公開鍵を生成

上記でOPの公開鍵情報は取得できました。

ただ、

  • OPの公開鍵そのものは取得できていない
  • 取得した公開鍵情報が、IDトークンの署名したときの鍵なのか分からない

のため、公開鍵情報をそのまま使うことができません。

そこで

  1. OPから取得した公開鍵情報の kid とIDトークンのヘッダーの kid が一致する公開鍵情報で署名したとみなす
  2. 公開鍵情報を元に、公開鍵を作成する

という2つの処理が必要になります。

なお、2.については、 ruby-jwtimport メソッドを使うことで、公開鍵情報から公開鍵を取得できるようです。
Ruby RSA from exponent and modulus strings - Stack Overflow

これらを decode メソッドのブロックに実装します。

JWT.decode(
  # ...
) do |jwt_header|
  # このブロックの中で、OPの公開鍵情報を取得
  # IDトークンのヘッダーのkidと等しい公開鍵情報を取得
  key = fetch_public_keys.find do |k|
    k['kid'] == jwt_header['kid']
  end

  # 等しいkidが見当たらない場合はエラー
  raise JWT::VerificationError if key.blank?

  # 公開鍵の作成
  JWT::JWK::RSA.import(key).public_key
end

 

IDトークンの検証

IDトークンの検証については、 decode メソッドのオプションに verify_iss: true などと設定することで、関係する各 claim を検証できます。
https://github.com/jwt/ruby-jwt#issuer-claim

 

IDトークンのペイロードを取得

ここまでの情報と

  • id_token
  • UserInfoエンドポイントから取得したsubject

より、IDトークンのペイロードを取得するメソッドが完成しました。

メソッドの全体像は以下となります。

def id_token_payload(id_token, subject_from_userinfo)
  # decodeできない場合はエラーを送出する
  payload, _header = JWT.decode(
    id_token, # JWT
    nil, # key: 署名鍵を動的に探すのでnil https://github.com/jwt/ruby-jwt#finding-a-key
    true, # verify: IDトークンの検証を行う
    { # options
      algorithm: 'RS256', # 署名は公開鍵方式なので、RS256を指定
      iss: ENV['ISSUER_OF_MY_OP'],
      verify_iss: true,
      aud: ENV['CLIENT_ID_OF_MY_OP'],
      verify_aud: true,
      sub: subject_from_userinfo,
      verify_sub: true,
      verify_expiration: true,
      verify_not_before: true,
      verify_iat: true
    }
  ) do |jwt_header|
    # このブロックの中で、OPの公開鍵情報を取得
    # IDトークンのヘッダーのkidと等しい公開鍵情報を取得
    key = fetch_public_keys.find do |k|
      k['kid'] == jwt_header['kid']
    end

    # 等しいkidが見当たらない場合はエラー
    raise JWT::VerificationError if key.blank?

    # 公開鍵の作成
    # https://stackoverflow.com/a/57402656
    JWT::JWK::RSA.import(key).public_key
  end

  payload
end

 

OmniAuthの AuthHash に入れる値を定義

OmniAuthでは、認証後の情報を AuthHash に入れます。

 
今回の場合、AuthHashには

を入れます。

具体的には

  • uid
    • UserInfoエンドポイントで取得した sub
      • IDトークンと一致することを確認済
  • info
    • UserInfoエンドポイントで取得した email
  • extra
    • raw_info
      • UserInfoエンドポイントで取得した情報
    • id_token
      • トークンエンドポイントから取得した値そのもの
    • id_token_payload

を指定します。

class MyOp < OmniAuth::Strategies::OAuth2
  uid do
    raw_info['sub']
  end

  info do
    {
      email: raw_info['email']
    }
  end

  extra do
    # access_token.params に hash として id_token が入っている
    # (他に、token_type='Bearer', scope='openid', created_at=<timestamp> が入ってる)
    id_token = access_token['id_token']

    {
      raw_info: raw_info,
      id_token_payload: id_token_payload(id_token, raw_info['sub']),
      id_token: id_token
    }
  end

  def raw_info
    # raw_infoには、UserInfoエンドポイントから取得できる情報を入れる
    @raw_info ||= access_token.get("#{ENV['OIDC_PROVIDER_HOST']}/oauth/userinfo").parsed
  end

 

RPでログインするユーザー用Modelの追加

OmniAuthにてAuthHashを作成するまでの設定が終わったため、OminAuthを使ってOPからユーザー情報を受け取れるようになりました。

次は、OPのユーザーをRPのユーザーとしてModel OpUser に保存し、RPのログイン/ログアウトに使用できるようにします。

OpUser には

  • provider
    • Userが所属するOP
  • uid
  • email
    • UserInfoエンドポイントで取得する claim に含まれる email
    • ただし、今回はOP Userのemail変更の同期はしないため、OPと値が異なる可能性あり

を項目として用意します。

% bin/rails g model op_user provider uid email
Running via Spring preloader in process 968
      invoke  active_record
      create    db/migrate/20210813014029_create_op_users.rb
      create    app/models/op_user.rb

 
また、 OpUserprovideruid で一意になることから、一意制約のindexをマイグレーションファイル <TIMESTAMP>_create_op_users.rb に追加しておきます。

class CreateOpUsers < ActiveRecord::Migration[6.1]
  def change
    create_table :op_users do |t|
      t.string :provider
      t.string :uid
      t.string :email

      t.timestamps
    end

    # 追加
    add_index :op_users, %i[provider uid], unique: true
  end
end

 

マイグレーションを実行

たまっているマイグレーションを実行します。

% bin/rails db:migrate

 

ログイン・ログアウトを管理するためのControllerを追加

続いて、ログイン・ログアウトを管理するためのControllerとして、 SessionsController を生成します。

% bin/rails g controller sessions create destroy
Running via Spring preloader in process 6477
      create  app/controllers/sessions_controller.rb
       route  get 'sessions/create'
get 'sessions/destroy'
      invoke  erb
      create    app/views/sessions
      create    app/views/sessions/create.html.erb
      create    app/views/sessions/destroy.html.erb
      invoke  helper
      create    app/helpers/sessions_helper.rb
      invoke  assets
      invoke    css
      create      app/assets/stylesheets/sessions.css

 
ここで、 create は認証レスポンス後の「リダイレクトURI」のアクションとなります。

また、 create アクションが呼ばれる前に、OmniAuthが

  • トークンエンドポイントやUserInfoエンドポイントにアクセス
  • IDトークンを検証
  • AuthHashを設定

を行っているため、 create アクションが呼ばれた時点でユーザーは認証済とみなせます。

 
そこで、 create アクションでのログイン処理では、

を行います。

一方、 destroy メソッドのログアウト処理では reset_session するだけです。

 
以上より、 app/contorllers/sessions_controller.rb の全体像は以下となります。

class SessionsController < ApplicationController
  # ログイン情報しかセッションに入れていないため、セッション情報の移し替えは不要
  before_action :reset_session

  def create
    op_user = OpUser.find_or_create_from_auth_hash!(request.env['omniauth.auth'])
    session[:user_id] = op_user.id
    redirect_to root_path, notice: 'ログインしました'
  end

  def destroy
    # 今のところ、OPのセッションはそのまま残る
    redirect_to root_path, notice: 'ログアウトしました'
  end
end

 

ApplicationControllerに、現在のユーザーを取得するヘルパーを追加

Viewから現在のユーザー情報を取得できるようにするため、ApplicationControllerにヘルパーメソッド current_user を追加します。
ruby on rails - What do helper and helper_method do? - Stack Overflow

class ApplicationController < ActionController::Base
  helper_method :current_user

  private

  def current_user
    # セッションのユーザーIDに紐づくOpUserが存在しない場合は、ログインしていないとみなす
    @current_user ||= OpUser.find_by(id: session[:user_id]) if session[:user_id]
  end
end

 

OpUserモデルに、AuthHashを元に生成 or 取得するメソッドを作成

SessionsController#create から呼ばれるクラスメソッド find_or_create_from_auth_hash! を作成します。

find_or_create_from_auth_hash! では、OmniAuthの AuthHash から該当する OpUser を取得します。

なお、AuthHashは以下のような内容です。

{
  "provider"=>"my_op",
  "uid"=>"1",
  "info"=>#<OmniAuth::AuthHash::InfoHash email="foo@example.com">,
  "credentials"=>#<OmniAuth::AuthHash
    expires=true
    expires_at=1628605105
    token="ACCESS_TOKEN">,
  "extra"=>#<OmniAuth::AuthHash
    id_token="HEADER.PAYLOAD.SIGNATURE"
    id_token_payload=#<Hashie::Array [
      #<OmniAuth::AuthHash
        aud="einUOs09X1VB8N9H_ZBo7iVVCRc2Qzc6eRUyiWQQHJU"
        exp=1628598025
        iat=1628597905
        iss="http://localhost:3780"
        sub="1">,
      #<OmniAuth::AuthHash
        alg="RS256"
        kid="Aa28UkVlSquSf4rtBSwDX0XnNda8et8y2OoUZV3EBg0"
        typ="JWT">
    ]>
    raw_info=#<OmniAuth::AuthHash
      email="foo@example.com"
      sub="1"
>>}

 
そこで、 auth_hash から OpUser モデルに必要な

  • provider
  • uid
  • email

を取り出し、検索 or 作成を行います。

class OpUser < ApplicationRecord
  def self.find_or_create_from_auth_hash!(auth_hash)
    provider = auth_hash[:provider]
    uid = auth_hash[:uid]
    email = auth_hash[:info][:email]

    OpUser.find_or_create_by!(provider: provider, uid: uid) do |op_user|
      op_user.email = email
    end
  end
end

 

認証リクエストを送るために、ControllerとViewを追加

今回は OIDC の認証リクエストを送るための画面として、 Home Viewを用意します。

Home Viewには以下の項目を表示します。

  • 未ログインの場合
    • ログインボタン
  • ログイン済の場合
    • ログアウトボタン
    • ログインユーザーのemail
    • 再度ログインするためのボタン

 
railsコマンドで生成します。

% bin/rails g controller home index
Running via Spring preloader in process 1478
      create  app/controllers/home_controller.rb
       route  get 'home/index'
      invoke  erb
      create    app/views/home
      create    app/views/home/index.html.erb
      invoke  helper
      create    app/helpers/home_helper.rb
      invoke  assets
      invoke    css
      create      app/assets/stylesheets/home.css

 
app/views/home/index.html.erb を編集します。

今回は layouts は使用せず、1つのViewにすべてを詰め込みます。

そのため、

  • OmniAuthのエンドポイント ( /auth/my_op ) へ POST するためのボタンを追加
  • ログインしている時は、ログアウトボタンを表示
  • flashメッセージを表示

を追加します。

<h1>RP Home</h1>
<div>
  <% if flash[:notice] %>
    <p>
      <%= flash[:notice] %>
    </p>
  <% end %>

  <% if current_user %>
    <p>Logged in as <strong><%= current_user.email %></strong></p>
    <p><%= link_to 'Logout', logout_path, id: 'logout' %></p>

    <div>Re Auth
      <%= form_tag('/auth/my_op', method: 'post') do %>
        <button type='submit'>Re Login</button>
      <% end %>
    </div>
  <% else %>
    <%= form_tag('/auth/my_op', method: 'post') do %>
      <button type='submit'>Login</button>
    <% end %>
  <% end %>
</div>

 

routes.rb を編集

ここまででルーティングに必要なものはすべて実装したため、 config/routes.rb を編集します。

Rails.application.routes.draw do
  # ルート設定
  root to: 'home#index'

  # OPからのコールバックURI
  get 'auth/:provider/callback', to: 'sessions#create'

  # 認証に失敗したときのルーティング
  get 'auth/failure', to: redirect('/')

  # ログアウト
  get 'logout', to: 'sessions#destroy'
end

 

動作確認

ここまでの実装ではセキュリティまわりに不備があるものの、OP・RPが動作するようになったため、動作確認をしてみます。

 

OPの起動

まずは OP を起動します。

# OPのディレクトリにて
% bin/rails s

 

OPにユーザーを作成

deviseでユーザーを作成します。

以下のURLにアクセスします。
http://localhost:3780/users/sign_up

今回は

項目
Email foo@example.com
Passowrd password

で登録します。

 

OPに、RPのアプリを追加する

doorkeeperの機能を使って、OAuth Applicationを登録します。

ログインした状態で、以下のURLにアクセスします(ログインしていない場合、ログイン画面へリダイレクトします)。 http://localhost:3780/oauth/applications

New Application をクリックし、以下の内容でアプリケーションを登録します。

項目
Name backend_rp
Redirect URI http://localhost:3781/auth/my_op/callback
Confidential [x] Application will be used ...
Scopes openid

 
画面に UIDSecret が表示されるため、 RPの .env ファイルに

キー
CLIENT_ID_OF_MY_OP UIDの値
CLIENT_SECRET_OF_MY_OP Secretの値

を追加します。

 

RPの起動

続いて、RPを起動します。

# RPのディレクトリにて
% bin/rails s

 

ログインを試す

RPのルートへアクセスします。 http://localhost:3781/

 
ログインボタンをクリックすると、OPのログインページへ遷移します。

 
ログインすると、同意画面が表示されます。

 
同意すると、RPに戻り、ログイン状態となります。

 
RPのログアウトボタンをクリックすると、ログアウトします。

 
RPのOpUserテーブルにも登録されています。

 
以上より、動作は良さそうです。

 

セキュリティ対策を追加

ここまでで RP <---> OP 間の動作確認はできましたが、まだセキュリティまわりに不備があります。

そのため、以下で記載されているセキュリティまわり対策の機能を追加していきます。

 

state

state ですが、RP側の omniauth-oauth2 では実装されています。
例: https://github.com/omniauth/omniauth-oauth2/blob/v1.7.1/lib/omniauth/strategies/oauth2.rb#L63

OP側の doorkeeper でも実装されています。
例: https://github.com/doorkeeper-gem/doorkeeper/blob/v5.5.2/lib/doorkeeper/oauth/code_response.rb#L25

 
また、ログを見ても、OP側は認可リクエストを受け取った時に state も受け取っています。

...
Started GET "/oauth/authorize?client_id=***&redirect_uri=***&response_type=code&scope=openid
&state=b68d3459455c7842083049f6f793c3f57098488196d5888b"
for 127.0.0.1 at 2021-08-13 19:22:21 +0900
...

 
RP側も、認可レスポンスの時に code の他に state も受け取っています。

Started POST "/auth/my_op" for 127.0.0.1 at 2021-08-13 19:22:18 +0900
Started GET "/auth/my_op/callback?code=***
&state=b68d3459455c7842083049f6f793c3f57098488196d5888b"
for 127.0.0.1 at 2021-08-13 19:22:21 +0900
...

 
これらより、 state は OP (doorkeeper) + RP (omniauth-oauth2) であれば、すでに実装されていることが分かりました。

 

PKCE

RP側のOmniAuthでは、READMEに従い、optionで PKCE の設定が必要です。
https://github.com/omniauth/omniauth-oauth2#creating-an-oauth2-strategy

module OmniAuth
  module Strategies
    class MyOp < OmniAuth::Strategies::OAuth2
      # ...
      # PKCEを使うように設定
      option :pkce, true
# ...

 
OP側のdoorkeeperでは、Wikiの記載に従い、PKCEを有効にします。
Using PKCE flow · doorkeeper-gem/doorkeeper Wiki

% bin/rails g doorkeeper:pkce
Running via Spring preloader in process 20723
      create  db/migrate/20210813110825_enable_pkce.rb

 
新しくマイグレーションファイルができたため、マイグレーションを実行します。

% bin/rails db:migrate

 
設定が終わったため、動作確認をします。

今までと同じくログインした後、OP側のログを確認します。

まずは認証リクエストのパラメータに

  • code_challenge
  • code_challenge_method

が追加されています。

Started GET "/oauth/authorize?client_id=***
&code_challenge=E7JbOKJ6DMO7JgsoztSmJdSOIFvRgYanN2BnR6EN0Ns
&code_challenge_method=S256
&redirect_uri=***&response_type=code&scope=openid&state=***" for 127.0.0.1 at 2021-08-13 20:10:50 +0900

 
また、トークンリクエストの際にも code_verifier が追加されています。

Started POST "/oauth/token" for 127.0.0.1 at 2021-08-13 20:10:50 +0900
Processing by Doorkeeper::TokensController#create as */*
  Parameters: {
      "client_id"=>"***", "client_secret"=>"[FILTERED]", "code"=>"[FILTERED]", 
"code_verifier"=>"34dd455edad41cedcc299fdc91c795f2aa9964fbde2e0b134c732322db62dfaa2094132fdfd5b3d4cfe766e508156345eb910ec6c2240e230faf2835f7e0afc9", 
      "grant_type"=>"authorization_code", "redirect_uri"=>"***"}

 
これにより、OP (doorkeeper) + RP (omniauth-oauth2) であれば、gemにあるPKCEの設定を追加すれば良いことが分かりました。

 

nonce

最後に nonce についてです。

nonceOpenID Connect で定義されたパラメータのため、OmniAuth OAuth2 では実装されていません。

そのため、RP側は独自に実装する必要があります。

 
一方、OP側 (doorkeeper) ですが、過去に doorkeeper 本体にPRが出ていたものの、reject されていました。

 
そこで、doorkeeper-openid_connect のREADMEを見ると、nonce に関する記載がありました。
https://github.com/doorkeeper-gem/doorkeeper-openid_connect#nonces

そのため、OP側はdoorkeeper-openid_connect で実装されているようです。

 

RP側を実装

RP側では

  • 認証リクエスト時に nonce を生成して、リクエストパラメータに追加
    • IDトークンの検証時に利用できるよう、生成した nonce はsessionに入れておく
  • トークンレスポンスで受け取る IDトークンに nonce が含まれるため、IDトークンの検証時の検証を追加する
    • nonce は一回のみ利用可能なため、IDトークンの検証時にセッションから nonce を削除する

の実装が必要になります。

なお、上記機能は「認証リクエスト〜トークンレスポンス」の間で必要なため、OmniAuthのストラテジーで実装します。

 
まずは、 nonce を生成するメソッドとして generate_nonce を用意します。

nonce の仕様を探したところ、以下の記事に記載がありました。ありがとうございます。
OAuth 2.0 / OpenID Connectにおけるstate, nonce, PKCEの限界を意識する - r-weblife

今回はRubySecureRandom#urlsafe_base64 を使って、安全な乱数発生器によるURLセーフなbase64文字列を生成します。
https://docs.ruby-lang.org/ja/latest/class/SecureRandom.html#S_URLSAFE_BASE64

また、生成した値はセッションに保存します。

def generate_nonce
  session['omniauth.nonce'] = SecureRandom.urlsafe_base64
end

 
次に、認証リクエストのパラメータに nonce を追加します。

OmniAuth OAuth2 で認証リクエストのパラメータを追加するには、メソッド authorize_params をオーバーライドすれば良さそうです。
https://github.com/omniauth/omniauth-oauth2/blob/v1.7.1/lib/omniauth/strategies/oauth2.rb#L62

def authorize_params
  super.tap do |params|
    params[:nonce] = generate_nonce
  end
end

ここまでで、認証リクエストまわりの実装が終わりました。

 
続いて、IDトークンの検証時に nonce も検証する処理を追加します。

まずはセッションから nonce を取り出すメソッドを用意します。

def nonce_from_session
  # nonceは再利用できないので、取り出したらsessionから消しておく
  session.delete('omniauth.nonce')
end

 
次に、IDトークンのペイロードに含まれる nonce を検証するためのメソッド verify_nonce! を実装します。

今回のRPは常に nonce を送信するため

  • IDトークンに nonce があること
  • IDトークンとセッションとで nonce が一致すること

を検証します。

def verify_nonce!(payload)
  # debug用
  nonce = nonce_from_session
  puts "session ===> #{nonce}"
  puts "payload ===> #{payload['nonce']}"
  return if payload['nonce'] && payload['nonce'] == nonce

  raise JWT::VerificationError
end

 
あとは、IDトークンのペイロードを取得した後に verify_nonce! メソッドの呼び出しを追加します。

def id_token_payload(id_token, subject_from_userinfo)
  # decodeできない場合はエラーを送出する
  payload, _header = JWT.decode(
    # ...
  ) do |jwt_header|
    # ...
  end

  # nonceの確認
  verify_nonce!(payload)

  payload
end

 
以上で RP側の実装が終わりました。

 

OP側を実装

READMEに従って実装します。
https://github.com/doorkeeper-gem/doorkeeper-openid_connect#nonces

doorkeeperのViewをカスタマイズする必要があるため、viewを生成します。

% bin/rails generate doorkeeper:views
Running via Spring preloader in process 22964
      create  app/views/doorkeeper
      create  app/views/doorkeeper/applications/_delete_form.html.erb
      create  app/views/doorkeeper/applications/_form.html.erb
      create  app/views/doorkeeper/applications/edit.html.erb
      create  app/views/doorkeeper/applications/index.html.erb
      create  app/views/doorkeeper/applications/new.html.erb
      create  app/views/doorkeeper/applications/show.html.erb
      create  app/views/doorkeeper/authorizations/error.html.erb
      create  app/views/doorkeeper/authorizations/form_post.html.erb
      create  app/views/doorkeeper/authorizations/new.html.erb
      create  app/views/doorkeeper/authorizations/show.html.erb
      create  app/views/doorkeeper/authorized_applications/_delete_form.html.erb
      create  app/views/doorkeeper/authorized_applications/index.html.erb
      create  app/views/layouts/doorkeeper
      create  app/views/layouts/doorkeeper/admin.html.erb
      create  app/views/layouts/doorkeeper/application.html.erb

 
生成されたViewのうち、 app/views/doorkeeper/authorizations/new.html.erb を開き、 nonce を hidden として追加します。

なお、PKCE用の

  • code_challenge
  • code_challenge_method

の2つの hidden は削除しないように注意します。
https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-PKCE-flow#enable-pkce-in-doorkeeper

<main role="main">
  <p class="h4">
    <%= raw t('.prompt', client_name: content_tag(:strong, class: 'text-info') { @pre_auth.client.name }) %>
  </p>

  <% if @pre_auth.scopes.count > 0 %>
    <div id="oauth-permissions">
      <p><%= t('.able_to') %>:</p>

      <ul class="text-info">
        <% @pre_auth.scopes.each do |scope| %>
          <li><%= t scope, scope: [:doorkeeper, :scopes] %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="actions">
    <%= form_tag oauth_authorization_path, method: :post do %>
      ...
      <%= hidden_field_tag :nonce, @pre_auth.nonce %>
      ...
    <%= form_tag oauth_authorization_path, method: :delete do %>
      ...
      <%= hidden_field_tag :nonce, @pre_auth.nonce %>
      ...

 

動作確認

ログインが完了した後、OP・RPのログを確認します。

OPのログにて、認証リクエストの際 nonce が送信されていました。

Started GET "/oauth/authorize?client_id=***&code_challenge=***&code_challenge_method=***
&nonce=PTY0zJhU-Czk7WQ4l7hxjg
&redirect_uri=***&response_type=code&scope=openid&state=***" for 127.0.0.1 at 2021-08-13 21:30:44 +0900

 
また、 RPのログにて、 puts した nonce

session ===> PTY0zJhU-Czk7WQ4l7hxjg
payload ===> PTY0zJhU-Czk7WQ4l7hxjg

と出力されていました。

これにより、 nonce の動作を確認できました。

 

ソースコード

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

また、 rails new 以降の OP/RPの実装は、以下のPRにまとめました。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample/pull/1

ブログのデザインを調整してみた

「昔やったことがある気がする」という時に、自分のブログを検索することがよくあります。

ただ、2014年からブログのデザインを変更していないせいか、なんとなく読みづらく感じていました。

振り返ってみると、2014年当時のブログ環境からは

  • 開発環境がWindowsからMacに変わった
  • 大きな外付けモニターが増えた
  • いろんな界隈のブログを読むことが増えた
  • (加齢...)

など、いろいろな変化がありました。

 
また、はてなダイアリー時代からの歴史的経緯により、今までは見出しを h4 始まりで書いていました。

ただ、目次記法 [:contents] が追加されたこともあり、見出しを h1 始まりで書きたくなりました。

そこでブログのデザインを調整してみました。

 
目次

 

やったこと

主に以下を調整しました。

 

やっていないこと

記事が700を超えていることもあり、時間の都合上、過去記事のメンテナンスはあきらめました。。。

特にはてなダイアリー時代のデザインが崩れているかもしれませんので、修正が必要そうであればお知らせください。

 

ソースコード

Githubにある、自分のはてなブログのデザインCSSを更新しました。
https://github.com/thinkAmi/hatenablog_custom_css

 
以降、メンテナンス用の見本を置いておきます。

 

メンテナンス用見本 (h1タグ)

ソースコード例です。

# ハローワールド
print('Hello, world!')

 

h2タグです

そろそろりんごシーズンです。

などが出てきています。

 

h3タグです

シナノゴールドについてです。

果肉は淡黄色で、香りがあり多汁で、きめは中位で口当たりがよく、蜜はあまり見られません。十分な酸があるので、まだ糖が十分に作られていない 未熟な果実は相当に酸っぱいものですが、完熟果は美味しいりんごです。

シナノゴールド | 青い森の片隅から

 

h4タグです

今までの記事は、h4タグから始めていることが多いです。

 

h5タグです

りんごは

  1. 産直所で買う
  2. 冷蔵庫で冷やす
  3. 皮を向いてまるかじり

な流れで食べています。

 

h6タグです

8月〜12月くらいまで、りんごをおいしくいただいています。

特に、品種の入れ替わりが激しい 9月〜10月が楽しみです。

Railsで、Action Mailerの callback・interceptor・observer の実行タイミングを調べてみた

RailsのAction Mailerには

などのフィルターやフックがあります。

ただ、これらの実行タイミングが分からなかったため、調べた時のメモです。

なお、今回は

  • メール送信が同期/非同期
  • メールサーバへの送信が成功/失敗

を組み合わせた4パターンで調べてみます。

 
目次

 

環境

  • Rails 6.1.4
  • letter opener 1.7.0
  • delayed_job_active_record 4.1.6
    • メールを非同期で送信するために、Active Jobのバックエンドとして利用

 

Action Mailerを使うアプリの準備

今回は

  • /hook/now にアクセスすると、メールを同期送信
  • /hook/later にアクセスすると、メールを非同期送信

とします。

また、メールサーバへの通信は

  • letter_opener を使うことで、メールサーバへの通信が成功したとみなせる
  • デフォルトのSMTP設定を使うことで、メールサーバへの通信が失敗しなとみなせる
    • デフォルトでは正しいSMTP設定になっていないため

として、 config/environments/development.rb へ設定することとします。

 

Railsアプリの生成とgem追加

Railsアプリを作成します。

% bundle exec rails new rails_mailer_app --skip-javascript --skip-turbolinks --skip-sprockets --skip-test

今回使うものをGemfileに追加します。

group :development do
  gem 'letter_opener'
end

gem 'delayed_job_active_record'

 

Delayed Jobのセットアップ

Delayed JobのREADMEに従ってセットアップします。
collectiveidea/delayed_job: Database based asynchronous priority queue system -- Extracted from Shopify

% bin/rails generate delayed_job:active_record
Running via Spring preloader in process 83213
      create  bin/delayed_job
       chmod  bin/delayed_job
      create  db/migrate/20210723085718_create_delayed_jobs.rb


% bin/rails db:migrate
== 20210723085718 CreateDelayedJobs: migrating ================================
-- create_table(:delayed_jobs)
   -> 0.0033s
-- add_index(:delayed_jobs, [:priority, :run_at], {:name=>"delayed_jobs_priority"})
   -> 0.0008s
== 20210723085718 CreateDelayedJobs: migrated (0.0043s) =======================

 

Mailerのセットアップ

Mailerを生成します。

% bin/rails g mailer MyMailer hello

 
生成されたMailerに対し、

  • before_action
  • after_action

を追加します。

 
今回は動作したことを確認するため、 logger.info を使ってログへ出力します。

Mailerなので、 logger で動作します。
2.3 メッセージ送信 | Rails アプリケーションのデバッグ - Railsガイド

class MyMailer < ApplicationMailer
  before_action :log_at_before_action
  after_action :log_at_after_action

  def hello
    @greeting = 'Hi'

    mail to: 'to@example.org'

   logger.info('<====== [Run hello] =======>')
  end

  private

  def log_at_before_action
   logger.info('======= [Before] =======>')
  end

  def log_at_after_action
   logger.info('<====== [After] ========')
  end
end

 

Controllerのセットアップ

Mailerができたので、Controllerを生成します。

% bin/rails g controller hook now later --no-helper --no-assets

 
Controllerの中でMailerを起動します。

now アクションは同期送信、laterアクションは非同期送信としています。

class HookController < ApplicationController
  def now
    MyMailer.hello.deliver_now
  end

  def later
    MyMailer.hello.deliver_later
  end
end

 

Interceptorの作成

app/mailers/my_mailer_interceptor.rb として作成します。

Interceptorとして動作するよう、クラスメソッド delivering_email を定義し、その中でログを出力しています。

class MyMailerInterceptor
  def self.delivering_email(mail)
    Rails.logger.info('<====== [Interceptor] =======>')
  end
end

 

Observerの作成

app/mailers/my_mailer_observer.rb として作成します。

こちらもクラスメソッド delivered_email を定義します。

なお、引数 message のクラス名も確認できるよう、ログに出力します。

class MyMailerObserver
  def self.delivered_email(message)
    Rails.logger.info('======= [Observer] =======>')
    Rails.logger.info(message.class.to_s)
    Rails.logger.info('<====== [Observer] ========')
  end
end

 

config/environments/development.rb への追記

メールまわりの設定を追記します。

なお、パターンごとの設定はコメントアウトしておき、動作確認する時にアンコメントしていきます。

# 追加
# メールの動作確認
# interceptorは常に実行
config.action_mailer.interceptors = ['MyMailerInterceptor']
# observerは常に実行
config.action_mailer.observers = ['MyMailerObserver']
# メール送信時のエラーは無視しない
config.action_mailer.raise_delivery_errors = true
# メール配信を行う
config.action_mailer.perform_deliveries = true

# パターン1. 同期送信、letter_openerを使用
# config.action_mailer.delivery_method = :letter_opener

# パターン2. 同期送信、存在しないSMTPサーバを使用
# config.action_mailer.delivery_method = :smtp

# パターン3. 非同期送信、letter openerの設定
# config.active_job.queue_adapter = :delayed_job
# config.action_mailer.delivery_method = :letter_opener

# パターン4. 非同期送信、存在しないSMTPサーバ
# config.active_job.queue_adapter = :delayed_job
# config.action_mailer.delivery_method = :smtp

 
以上で、確認するための準備ができました。

 

パターン1. 同期送信、letter_openerを使用

config/environments/development.rb

config.action_mailer.delivery_method = :letter_opener

を有効にして実行すると、

Started GET "/hook/now" for 127.0.0.1 at 2021-07-23 20:53:17 +0900
Processing by HookController#now as HTML
======= [Before] =======>
  Rendered layout layouts/mailer.text.erb (Duration: 0.1ms | Allocations: 118)
<====== [Run hello] =======>
<====== [After] ========
MyMailer#hello: processed outbound mail in 5.2ms
<====== [Interceptor] =======>
Delivered mail 60faadad696f5_15d7e43e41150@...
======= [Observer] =======>
Mail::Message
<====== [Observer] ========

と、

  1. before_action
  2. Mailerのhelloメソッド
  3. after_action
  4. interceptor
  5. observer

の順で実行されました。

 

パターン2. 同期送信、存在しないSMTPサーバを使用

config/environments/development.rb

config.action_mailer.delivery_method = :smtp

を有効にして実行すると、画面に Connection refused - connect(2) for "localhost" port 25 エラーが表示されました。

また、ログには

Started GET "/hook/now" for 127.0.0.1 at 2021-07-23 20:58:06 +0900
======= [Before] =======>
  Rendered layout layouts/mailer.text.erb (Duration: 0.5ms | Allocations: 287)
<====== [Run hello] =======>
<====== [After] ========
MyMailer#hello: processed outbound mail in 12.9ms
<====== [Interceptor] =======>
Delivered mail 60faaece837ed_16bcd2a30984@...
...
Completed 500 Internal Server Error in 75ms (ActiveRecord: 0.0ms | Allocations: 42200)

Errno::ECONNREFUSED (Connection refused - connect(2) for "localhost" port 25):

と出力されました。

  1. before_action
  2. Mailerのhelloメソッド
  3. after_action
  4. interceptor

までは実行されたものの、 observer は実行されませんでした。

 

パターン3. 非同期送信、letter openerを使用

config/environments/development.rb

config.active_job.queue_adapter = :delayed_job
config.action_mailer.delivery_method = :letter_opener

を有効にして hook/later にアクセスした時は

Started GET "/hook/later" for 127.0.0.1 at 2021-07-23 21:02:34 +0900
...
[ActiveJob] Enqueued ActionMailer::MailDeliveryJob (Job ID: 922d375d-04eb-4f3e-bd9c-f9340ddce72f) to DelayedJob(default) with arguments: "MyMailer", "hello", "deliver_now", {:args=>[]}

と、Mailerまわりのログが出力されませんでした。

その後、

% bin/rails jobs:work

を実行したところ

[ActiveJob] [ActionMailer::MailDeliveryJob] [...] ======= [Before] =======>
[ActiveJob] [ActionMailer::MailDeliveryJob] [...]   Rendered layout layouts/mailer.text.erb (Duration: 0.4ms | Allocations: 242)
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] <====== [Run hello] =======>
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] <====== [After] ========
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] MyMailer#hello: processed outbound mail in 23.9ms
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] <====== [Interceptor] =======>
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] Delivered mail 60fab03938356_16f61e247966d@
...
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] ======= [Observer] =======>
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] Mail::Message
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] <====== [Observer] ========
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] Performed ActionMailer::MailDeliveryJob (Job ID: 922d375d-04eb-4f3e-bd9c-f9340ddce72f) from DelayedJob(default) in 101.14ms

と、Active Jobの方で

  1. before_action
  2. Mailerのhelloメソッド
  3. after_action
  4. interceptor
  5. observer

の順に実行されました。

 

パターン4. 非同期送信、存在しないSMTPサーバを使用

config/environments/development.rb

config.active_job.queue_adapter = :delayed_job
config.action_mailer.delivery_method = :smtp

を有効にして hook/later にアクセスした時は

Started GET "/hook/later" for 127.0.0.1 at 2021-07-23 21:12:48 +0900
...
[ActiveJob] Enqueued ActionMailer::MailDeliveryJob (Job ID: a73c673e-63e6-412b-802d-59108998efd6) to DelayedJob(default) with arguments: "MyMailer", "hello", "deliver_now", {:args=>[]}

と、Mailerまわりのログが出力されませんでした。

その後、

% bin/rails jobs:work

を実行したところ、ジョブのログに

[Worker(host:*** pid:95801)] 1 jobs processed at 22.9310 j/s, 1 failed

と出力されました。

また、サーバのログにも

2021-07-23T21:14:40+0900: [Worker(host:*** pid:95801)] Starting job worker
...
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] Performing ActionMailer::MailDeliveryJob (Job ID: 28ee4556-11de-4f8e-bf39-6caa02f56533) from DelayedJob(default) enqueued at 2021-07-23T12:14:27Z with arguments: "MyMailer", "hello", "deliver_now", {:args=>[]}
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] ======= [Before] =======>
...
[ActiveJob] [ActionMailer::MailDeliveryJob] [...]   Rendered layout layouts/mailer.text.erb (Duration: 0.6ms | Allocations: 243)
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] <====== [Run hello] =======>
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] <====== [After] ========
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] MyMailer#hello: processed outbound mail in 32.0ms
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] <====== [Interceptor] =======>
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] Delivered mail 60fab2b0b41fc_17639e2445447@
...
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] Error performing ActionMailer::MailDeliveryJob (Job ID: 28ee4556-11de-4f8e-bf39-6caa02f56533) from DelayedJob(default) in 107.6ms: Errno::ECONNREFUSED (Connection refused - connect(2) for "localhost" port 25):

と、

  1. before_action
  2. Mailerのhelloメソッド
  3. after_action
  4. interceptor

までは実行されたものの、 observer は実行されませんでした。

 

ソースコード

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

関係するプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_mailer_app/pull/1

Rails + Active Adminで、Active Admin向けのテストコードを request spec で書いてみた

Active AdminのControllerに手を加えた際、テストコードがほしくなりました。

Wikiを見たところ、controller specでの実装でした。
Testing your ActiveAdmin controllers with RSpec · activeadmin/activeadmin Wiki

ただ、現在では controller spec よりも request spec が推奨されています。
Rails: Rails 5 のサポート | RSpec 3.5 がリリースされました!

 
そこで、Active Admin向けのテストコードを request spec で書いてみました。

 
目次

 

環境

  • Ruby 3.0.1
  • Rails 6.1.4
  • Active Admin 2.9.0
  • Devise 4.8.0
    • Active Adminの管理ページをログイン必須にするために使用
  • rspec-rails 5.0.1

 

プロダクションコードの準備

前回の記事のコードを一部修正してプロダクションコードとします。

 

Modelにバリデーションを追加

name を入力必須にします。

# app/models/fruit.rb

class Fruit < ApplicationRecord
  validates :name, presence: true
end

 

Active Adminで Model を作成する時にトランザクションを追加

オーバーライドしていた controllerの create メソッドを変更し、

を追加します。

なお、flashについては、

  • renderのときは flash.now
  • リダイレクトのときは flash

を使います。
5.2 Flash | Action Controller の概要 - Railsガイド

ActiveAdmin.register Fruit do
  permit_params :name, :color

  controller do
    def create
      ApplicationRecord.transaction do
        super do |format|
          if @fruit.valid?
            call_api_with_params(permitted_params[:fruit][:name])
            # redirectするので flash
            flash[:notice] = 'success'
          else
            # バリデーションエラー時はrenderされるので flash.now
            flash.now[:alert] = 'wrong!'
          end
        end
      end

    rescue StandardError
      flash.now[:error] = 'exception!'
      render :new
    end

    private

    def call_api_with_params(name)
      logger.info("======> call api with #{name}")
    end
end

 

画面での動作確認
正常

 

バリデーションエラー

 

APIエラー

今のプロダクションコードでは発生し得ないので、メソッド call_api_with_params で例外が出るように修正した時の表示となります。

 

テストコードの準備

プロダクションコードができたので、次は request spec を書きます。まずは準備です。

 

rspecまわり

Gemfileに

を追加し、bundle installします。

group :development, :test do
  gem 'factory_bot_rails'
  gem 'rspec-rails'
end

 
続いて、rspecの初期設定とrequest specの雛形を生成します。

% bin/rails generate rspec:install
Running via Spring preloader in process 37340
      create  .rspec
       exist  spec
      create  spec/spec_helper.rb
      create  spec/rails_helper.rb

% bin/rails generate rspec:request fruit
Running via Spring preloader in process 37150
      create  spec/requests/fruits_spec.rb

 

rexmlの追加

rspecを実行したところ、以下のエラーが発生しました。

% bin/rails spec spec/requests/admin
...
An error occurred while loading ./spec/requests/admin/fruits_spec.rb. - Did you mean?
                    rspec ./spec/factories/admin_user.rb

Failure/Error: require File.expand_path('../config/environment', __dir__)

LoadError:
  cannot load such file -- rexml/document

 
原因は、Ruby3.0.1 の場合、rexml gemが不足しているためでした。
Rails 6.1, Ruby 3.0.0: tests error as they cannot load rexml - Stack Overflow

そこで、 rexml もGemfileに追加してインストールします。

gem 'rexml'

 

factory_botによる admin user 作成

今回のActive AdminはDeviseによる認証を行っています。

そのため、テストコード中も admin user でログインする必要があります。

そこで、factory_bot を使って admin userを作成できるようにします。

 
まずは、spec/rails_helper.rbFactoryBot::Syntax::Methods を追加します。
Configure your test suite | Setup | factory_bot/GETTING_STARTED.md at master · thoughtbot/factory_bot

config.include FactoryBot::Syntax::Methods

 
続いて、生成する admin user の設定を行います。

admin_userのfactoryは、 spec/factories/admin_user.rb に作成します。

複数のadmin userをfactory_botで生成しても問題が起こらないよう、メールアドレスはシーケンスにします。
https://github.com/thoughtbot/factory_bot/blob/master/GETTING_STARTED.md#inline-sequences

また、Deviseで生成した admin userは、パスワード項目として passwordpassword_confirmation の2つが必要になるため、それぞれ設定します。
How To: Test controllers with Rails (and RSpec) · heartcombo/devise Wiki)

FactoryBot.define do
  factory :admin_user do
    sequence(:email) { |n| "person#{n}@example.com" }
    password { 'password' }
    password_confirmation { 'password' }
  end
end

 

テストコード中で sign_in できるようにする

テストコード中でのログインを容易にするため、spec/rails_helper.rb にDeviseの Devise::Test::IntegrationHelpers を追加します。
heartcombo/devise: Flexible authentication solution for Rails with Warden.

config.include Devise::Test::IntegrationHelpers

 
以上で準備ができました。

 

正常系の request spec

spec/requests/admin/fruit_spec.rb に作成します。

テストコードでは

あたりを頭に置いて実装します。

require 'rails_helper'

RSpec.describe 'Admin::Fruits', type: :request do
  let(:admin_user) { create(:admin_user) }

  before do
    sign_in admin_user
  end

  describe '#create' do
    let(:name) { 'りんご' }
    let(:color) { '#000000' }
    let(:params) { { fruit: { name: name, color: color } } }

    context '登録に成功した場合' do
      before { post admin_fruits_path, params: params } # pathは複数形

      it 'Fruitが登録されていること' do
        fruit = Fruit.find_by(name: name)
        expect(fruit).not_to eq nil
        expect(fruit.color).to eq nil
        expect(fruit.start_of_sales).to eq nil
      end

      it '作成したFruitの詳細画面へリダイレクトしていること' do
        expect(response).to have_http_status '302'
        fruit = Fruit.find_by(name: name)
        expect(response).to redirect_to(admin_fruit_path(fruit)) # pathは単数形
      end

      it 'リダイレクト先の画面にflashが表示されていること' do
        follow_redirect!

        expect(response.body).to include 'success'
      end
    end

# ...

 

nameを入力しない場合の request spec

こちらも同様な形で検証します。

なお、contextの中で name を上書きしているため、このcontextの中では name の値が nil になっています。

context 'nameが未入力でエラーの場合' do
  # describeで定義した name を上書き
  let(:name) { nil }

  before { post admin_fruits_path, params: params }

  it 'Fruitが登録されていないこと' do
    expect(Fruit.find_by(name: name)).to eq nil
  end

  it '作成したFruitの登録画面のままであること' do
    expect(response).to have_http_status '200'
  end

  it 'エラーが表示されていること' do
    expect(response.body).to include 'be blank'
  end

  it 'エラーのflashが表示されていること' do
    expect(response.body).to include 'wrong!'
  end
end

 

外部APIの呼び出しで例外が発生した場合の request spec

現在のプロダクションコードでは、外部APIの呼び出し時には例外が発生しません。

そこで、外部APIを呼び出しているメソッド call_api_with_params で例外が発生するよう、メソッドを差し替えます。

また、Active AdminのControllerは、デフォルトでは Admin::<Model名>Controller という名前になるため、今回は Admin::FruitsController に対して差し替えを行います。

 
なお、request specではControllerのインスタンスをどのように差し替えるのが適切か分からなかったため、 expect_any_instance_of でControllerのどのインスタンスでも例外が発生するようにしています。

もし、より良い方法をご存じの方がいれば、教えていただけるとありがたいです。

context 'nameを含むリクエストを送ったものの、APIでエラーになった場合' do
  before do
    expect_any_instance_of(Admin::FruitsController).to receive(:call_api_with_params)
                                                         .with(name)
                                                         .once
                                                         .and_raise(StandardError)
    post admin_fruits_path, params: params
  end

  it 'Fruitが登録されていないこと' do
    expect(Fruit.find_by(name: name)).to eq nil
  end

  it '作成したFruitの登録画面のままであること' do
    expect(response).to have_http_status '200'
  end

  it 'エラーのflashが表示されていること' do
    expect(response.body).to include 'exception!'
  end
end

 
以上のように、Active Admin向けのテストコードを request spec で書けることが分かりました。

 

ソースコード

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

 
関係するプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_with_active_admin_app/pull/3

Active AdminのControllerでは、Strong Parameters のメソッド名は permitted_params だった

Rails の Controller で Strong Parametersを使う場合、 ***_params というプライベートメソッドを定義しています。

一方、Active Admin の Controller で Strong Parameters を使う時は、デフォルトだとどんな名前になるのかを調べた時のメモです。

 
目次

 

環境

  • Rails 6.1.4
  • Active Admin 2.9.0

 

permitted_params で取り出せた

公式ドキュメントにありました。
Setting up Strong Parameters | Active Admin | The administration framework for Ruby on Rails

permit_params で定義した項目は、 permitted_params から取り出せます。

 
そこで、前回の記事で使ったActive Adminのコードを一部修正し、

ActiveAdmin.register Fruit do
  permit_params :name, :color  # 定義

  # コントローラのcreate/update/destroyをオーバーライド
  controller do
    def create
      super do |format|
        # Strong Parameters から取り出し
        call_api_with_params(permitted_params[:fruit][:name])
      end
    end

    def call_api_with_params(name)
      logger.info("======> call api with #{name}")
    end
  end
end

のように、

  • create メソッドをオーバーライド
  • createメソッドの中で、StrongParametersの name を使ってAPIを呼ぶ
    • 今回はログに出力するだけ

な実装としました。

そして、Active Adminの画面で

  • name: ぶどう
  • color: #b46fe2
  • start_of_sales: 2021/7/19 00:04

の値を入力して保存します。

すると、ログには

Started POST "/admin/fruits" for 127.0.0.1 at 2021-07-19 00:04:11 +0900
Processing by Admin::FruitsController#create as HTML
  Parameters: {"authenticity_token"=>"[FILTERED]", "fruit"=>{"name"=>"ぶどう", "color"=>"#b46fe2", "start_of_sales(1i)"=>"2021", "start_of_sales(2i)"=>"7", "start_of_sales(3i)"=>"19", "start_of_sales(4i)"=>"00", 
...
Unpermitted parameters: :start_of_sales(1i), :start_of_sales(2i), :start_of_sales(3i), :start_of_sales(4i), :start_of_sales(5i)
======> call api with ぶどう

と出力されました。

permitted_params[:fruit][:name]ぶどう が取り出せていました。

 

permitted_paramsのオーバーライド

Active Adminのデフォルトでは permit_params にて制御しています。

ただ、何らかの理由によりStrong Paramterの値を変更したい場合は、 permitted_params メソッドをオーバーライドします。

例えば

ActiveAdmin.register Fruit do
  permit_params :name, :color

  # コントローラのcreate/update/destroyをオーバーライド
  controller do
    # ...

    def permitted_params
      params.permit(:fruit => [:name])

      # 以下の書き方だとエラーになる
      # param is missing or the value is empty: fruit
      # params.require(:fruit).permit(:name)
    end

# ...

のようにすると、ログには

Unpermitted parameters: :color, :start_of_sales(1i), :start_of_sales(2i), :start_of_sales(3i), :start_of_sales(4i), :start_of_sales(5i)
Unpermitted parameters: :authenticity_token, :commit
{"fruit"=>#<ActionController::Parameters {"name"=>"ぶどう"} permitted: true>}
======> call api with ぶどう

のように出力されました。

Active Adminの結果も、以下のように color の値が入りませんでした。

 
なお、上記以外の書き方については、Active Adminのドキュメントや、Inherited ResourcesのREADMEにも記載がありました。

 

ソースコード

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

関係するプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_with_active_admin_app/pull/2