2021/08/26 00:42 追記
ご指摘いただいた内容に合わせ、記事を修正しました。
主な修正点は以下の通りです。
- タイトルをOAuth2.0な文脈へ変更
- 環境に Resource Server を追加し、Resource Serverの中で Authorization Server の Introspection エンドポイントを呼び出すよう修正
- これで、RS -> AS な挙動となったはず
- それに合わせ、Client の中で行っていた Introspection エンドポイントの呼び出しを削除
- OpenID Connect 向けの表記を OAuth 向けの表記に改めた
2021/08/26 00:42 追記ここまで
2021/08/23 22:20 追記
この記事を公開したところ、Twitterにてご指摘をいただきました。ありがとうございました。
トークン管理系のエンドポイントの挙動を見てみたよという記事。
— 👹秋田の猫🐱 (@ritou) 2021年8月22日
細かいところで、これらは
* Revocation: Client->AS
* Introspection: RS->AS
を想定していることに留意すると良いでしょうhttps://t.co/9ahU5zI9sv
ご指摘の通り、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
のような識別子型でした。
そのため、もし今後
を試す時
がある場合は、 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)
- Client
- Resource Server (RS)
- アクセストークンの状態
- 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.rb
の access_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 の修正
同じストラテジーを使うため、同じストラテジーを複数回使えるよう name
に introspection
を渡します。
また、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へリクエストしレスポンスを受け取ります。
そこで、
の2つのメソッドを用意します。
また、今回はいずれもFaradayを使ってみます。
Usage | Faraday
RSのAPIへGETするメソッドを追加
今回、RSのAPIにアクセスするにはASから渡された access_token が必要だとします。
Auth屋さんの本「【電子版】雰囲気でOAuth2.0を使っているエンジニアがOAuth2.0を整理して、手を動かしながら学べる本 - Auth屋 - BOOTH」より、Authorization
ヘッダに Bearer
+ access_token をセットすることで、RSのAPIに access_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 APIで access_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
などの値へ設定します。
実行
今回は
http://localhost:3781/introspection
にアクセスし、Login
ボタンをクリック- ASでログイン
- ASの認可画面で許可
- 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