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