OAuth 2.0 Token Exchange の概要
マイクロサービスパターンでは、クライアントアプリケーションが呼び出している API は、実際には API ゲートウェイ経由でバックエンド API を呼び出すことで要求する処理を実行することが一般的です。
API ゲートウェイは大抵の場合、 OAuth 2.0 や OpenID Connect によって保護されます。
クライアントアプリケーションが保護された API ゲートウェイと通信し、さらにその先の他の保護されたバックエンド API とやり取りする必要がある場合に、クライアントが利用している OAuth アクセストークンをそのまま再利用してしまうと、 本来 API ゲートウェイを保護対象とするアクセストークンをバックエンド API が受け入れる必要があり、あまり良いとは言えません。
上記の問題を解決すべく、 OAuth ワーキンググループでは、OAuth 2.0 Token Exchange と呼ばれる仕様の標準化に取り組んでいます。
OAuth 2.0 Token Exchange は、ある API に対するアクセストークンを、別の API に渡すためのセキュリティトークンを OAuth 2.0 認可サーバとやりとりして取得する方法を定義しています。
上記は、OAuth 2.0 Token Exchange における典型的なフローを示す図です。
(引用: Delegation Patterns for OAuth 2.0(Scott Brady - Identity & Access Control))
1. まずクライアントアプリケーションが、 API ゲートウェイに対して JWT のアクセストークンを Bearer ヘッダとして付与して、API コールします。
2. API ゲートウェイは、 STS (Security Token Service) のトークンエンドポイントに対して、以下のような形式のアクセストークン交換のリクエストを送ります。(見やすさのため URL エンコードを外した形にしています)
subject_token
は API ゲートウェイに権限委任する元のアクセストークンを指定し、subject_token_type
はそのトークンのタイプを指定します。(アクセストークンの場合は、urn:ietf:params:oauth:token-type:access_token
を指定)
1 2 3 4 5 6 7 8 9 |
POST /as/token.oauth2 HTTP/1.1 Host: as.example.com Authorization: Basic cnMwODpsb25nLXNlY3VyZS1yYW5kb20tc2VjcmV0 Content-Type: application/x-www-form-urlencoded grant_type=urn:ietf:params:oauth:grant-type:token-exchange &resource=https://backend.example.com/api &subject_token=accVkjcJyb4BWCxGsndESCJQbdFMogUC5PbRDqceLTC &subject_token_type=urn:ietf:params:oauth:token-type:access_token |
3. STS は、 backend.example.com
向けの権限委任されたアクセストークンを返します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
HTTP/1.1 200 OK Content-Type: application/json Cache-Control: no-cache, no-store { "access_token":"eyJhbGciOiJFUzI1NiIsImtpZCI6IjllciJ9.eyJhdWQiOiJo dHRwczovL2JhY2tlbmQuZXhhbXBsZS5jb20iLCJpc3MiOiJodHRwczovL2FzLmV 4YW1wbGUuY29tIiwiZXhwIjoxNDQxOTE3NTkzLCJpYXQiOjE0NDE5MTc1MzMsIn N1YiI6ImJkY0BleGFtcGxlLmNvbSIsInNjb3BlIjoiYXBpIn0.40y3ZgQedw6rx f59WlwHDD9jryFOr0_Wh3CGozQBihNBhnXEQgU85AI9x3KmsPottVMLPIWvmDCM y5-kdXjwhw", "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", "token_type":"Bearer", "expires_in":60 } |
4. API ゲートウェイは、新しく取得したアクセストークンを Bearer ヘッダに付与して、バックエンド API にリクエストを送信します。
1 2 3 4 5 6 7 8 |
GET /api HTTP/1.1 Host: backend.example.com Authorization: Bearer eyJhbGciOiJFUzI1NiIsImtpZCI6IjllciJ9.eyJhdWQ iOiJodHRwczovL2JhY2tlbmQuZXhhbXBsZS5jb20iLCJpc3MiOiJodHRwczovL2 FzLmV4YW1wbGUuY29tIiwiZXhwIjoxNDQxOTE3NTkzLCJpYXQiOjE0NDE5MTc1M zMsInN1YiI6ImJkY0BleGFtcGxlLmNvbSIsInNjb3BlIjoiYXBpIn0.40y3ZgQe dw6rxf59WlwHDD9jryFOr0_Wh3CGozQBihNBhnXEQgU85AI9x3KmsPottVMLPIW vmDCMy5-kdXjwhw |
権限委任されたアクセストークンは JWT 形式で、そのペイロード部分は以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 |
{ "aud":"https://consumer.example.com", "iss":"https://issuer.example.com", "exp":1443904177, "nbf":1443904077, "act": { } } |
act(actor)
クレームは、委任が行われたことを意味するものです。
上記の例では、 [email protected]
が、 [email protected]
に代わってバックエンド API を実行する、ということを示しています。
The outermost "act" claim represents the current actor while nested "act" claims represent > prior actors.
とあるように、 act
クレームはネストすることが可能で委任の履歴を表現することができます。
また、may_act
クレーム というものも定義されています。
このクレームは、ある当事者が別の当事者として振る舞う権限を与えられていることを意味します。
1 2 3 4 5 6 7 8 9 10 11 12 |
{ "aud":"https://consumer.example.com", "iss":"https://issuer.example.com", "exp":1443904177, "nbf":1443904077, "may_act": { } } |
act
クレームと may_act
クレームの違いが分かりにくいですが、
act
クレームは、[email protected]
に代わって、[email protected]
として API を実行できることを示し、
may_act
クレームは、[email protected]
が[email protected]
の権限で API を実行できることを示していると私は解釈しています。
Keycloak で OAuth 2.0 Token Exchange を 試してみる
OAuth 2.0 Token Exchange はまだドラフト版ですが、 OSS のアイデンティティ・アクセス管理ソフトウェアである Keycloak で実装されているみたいですので、試してみました。
今回試すフロー図は以下の通りです。 Google から発行されたアクセストークンを、 Keycloak のアクセストークンに変換してみます。
Google の OAuth 認証情報取得
交換元のアクセストークン発行は、今回は Google を利用して試します。
Google Developer Console にアクセスして、
まずは、「 OAuth 同意画面」にて、アプリケーション名と、承認済みドメインを入力します。
※ 承認済みドメインは、Google との OpenID Connect Authrization Code フローにおけるリダイレクト URL に指定するドメインを指定します。リダイレクト URL に対して Authrization Code が発行されるので、試す場合でも実在するドメインがやりやすいでしょう。
次に、「認証情報」にて、 OAuth クライアント ID を作成します。
アプリケーションの種類は「ウェブアプリケーション」を選択し、承認済みのリダイレクト URI を入力します。
クライアント ID と、クライアントシークレットが発行されるので控えておきます。
Keycloak のセットアップ
次に Keycloak 側の設定を行なっていきます。
(今回は、Keycloak のバージョンは記事執筆時点(2019/09)の最新バージョンである7.0.0で試しています。)
まずはダウンロードします。
1 2 3 4 5 6 |
wget https://downloads.jboss.org/keycloak/7.0.0/keycloak-7.0.0.tar.gz tar xvpf keycloak-7.0.0.tar.gz mv keycloak-7.0.0 keycloak cd keycloak vi bin/standalone.sh |
次に bin/standalone.sh
の以下の行を修正します。
( Keycloak の Token Exchange 機能はテクノロジープレビュー版のため、利用できるようにするために起動オプションを付与しています。)
1 2 3 4 5 6 |
# 修正前 JAVA_OPTS="$PREPEND_JAVA_OPTS $JAVA_OPTS" ↓↓↓ # 修正後 JAVA_OPTS="$PREPEND_JAVA_OPTS $JAVA_OPTS -Dkeycloak.profile=preview" |
次に Keycloak のユーザを追加します。
1 2 3 4 |
USER_ID=shirakawa.hiroaki USER_PASSWORD=******** bin/add-user-keycloak.sh -r master -u ${USER_ID} -p ${USER_PASSWORD} |
bin/standalone.sh
を実行して、 Keycloak を起動します。
8080番ポートで Keycloak の管理画面にアクセスできるようになりますので、ブラウザに http://localhost:8080/auth/admin
にアクセスして、先ほど登録したユーザでログインします。
ログイン成功すると以下のような画面が表示されます。
次に、Keycloak 側の OAuth クライアントを登録します。
左側のメニューの Clients
から新規登録します。
デフォルトでアクセスタイプが Public となっているため、 Confidential に変更します。
※ Valid Redirect URIs を一つ以上登録する必要がありますが、使用しないため今回は適当で構いません。
アクセスタイプを Confidential に変更すると、 Credentials タブが増えますので、そこでクライアントシークレットを確認します。
次に Identity Provider
を登録します。
ここで先ほどの Google OAuth クライアント情報を設定していきます。
Google OAuth のクライアント ID とクライアントシークレットを入力して保存すると、 Permission タブが表示されます。
Permission を Enabled に変更すると、token-exchange
というスコープが表示されます。
token-exchange
をクリックして、 Token Exchange の設定を行ないます。
create policy
-> client
を選択して、ポリシーの登録をします。
ここで先ほど登録した Keycloak の クライアントと、 Google のクライアントを紐付けます。
交換してみる
ここまでで事前準備完了です。
実際に試していきましょう。
最初に、Google の Authorization Code フローを実行して、Google からアクセストークンを取得します。
ブラウザから以下の URL にアクセスします。(見やすさのため URL エンコードを外したり適宜改行を入れたりしていますが、実際には改行を入れずにアクセスしてください)
1 2 3 4 5 6 |
https://accounts.google.com/o/oauth2/v2/auth ?client_id=[Google OAuth 認証情報のクライアント ID] &response_type=code &scope=openid email &state=138r5719ru3e1 &redirect_uri=[Google OAuth 認証情報に登録したリダイレクト URI ] |
Google のログイン画面が表示されるので、ログインするとリダイレクト URI にリダイレクトされます。
以下のようにリダイレクト先で認可コードを確認することができると思います。(見やすさのため URL エンコードを外して適宜改行を入れています)
1 2 3 4 5 6 7 |
[リダイレクト URI]?state=138r5719ru3e1 &code=[認可コード] &scope=email openid https://www.googleapis.com/auth/userinfo.email &authuser=0 &hd=synergy101.jp &session_state=5012174bf5befddcdee06e14276146ad6c63ede8..8a95 &prompt=consent |
認可コードを取得できたので、 Google のトークンエンドポイントに curl でアクセストークン取得のリクエストを送ります。
1 2 3 4 5 6 7 8 |
curl -H 'Content-Type:application/x-www-form-urlencoded' \ -d "code=[認可コード]" \ -d "client_id=[Google OAuth 認証情報のクライアント ID]" \ -d "client_secret=[Google OAuth 認証情報のクライアントシークレット]" \ -d "redirect_uri=[Google OAuth 認証情報に登録したリダイレクト URI ]" \ -d "grant_type=authorization_code" \ https://www.googleapis.com/oauth2/v4/token |
以下のようなレスポンスを得ることができれば、 Google からのアクセストークン取得成功です。
1 2 3 4 5 6 7 8 |
{ "access_token": "ya29...", "expires_in": 3600, "scope": "openid https://www.googleapis.com/auth/userinfo.email", "token_type": "Bearer", "id_token": "eyJh..." } |
では、 Google のアクセストークンを Keycloak のアクセストークンに交換してみましょう。
curl で以下のようにトークン交換のリクエストを送ります。
1 2 3 4 5 6 7 8 9 |
curl -X POST \ -d "client_id=[Keycloak のクライアント ID]" \ -d "client_secret=[Keycloak のクライアントシークレット]" \ --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \ -d "subject_token=ya29..." \ -d "subject_issuer=google" \ --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \ http://localhost:8080/auth/realms/master/protocol/openid-connect/token |
以下のようなレスポンスが返ってきます。
1 2 3 4 5 6 7 8 9 10 11 |
{ "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJiU1Nka05FMjNfUkNzMXlaUzJOdkJJNnJQTHpmZXlsSGtheHNwNlAzd3hZIn0.eyJqdGkiOiJiMmE1MmVjMS1jMTY1LTQ0NGItOGNhNC02YzkwNDU1N2YyODYiLCJleHAiOjE1Njc1MDQ3ODcsIm5iZiI6MCwiaWF0IjoxNTY3NTA0NzI3LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvbWFzdGVyIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6ImMwNDIwNWQxLTBlNmMtNDFjMy04NTkzLTFkYmZhNGZkZjdlMyIsInR5cCI6IkJlYXJlciIsImF6cCI6InRhcmdldGNsaWVudCIsImF1dGhfdGltZSI6MCwic2Vzc2lvbl9zdGF0ZSI6IjI5MTI2ZWRkLWQyZWYtNDkwMS05NzU5LTdjNTM1MzFlODNiOCIsImFjciI6IjEiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoicHJvZmlsZSBlbWFpbCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6IueZveW3neWkp-aclyIsInByZWZlcnJlZF91c2VybmFtZSI6InNoaXJha2F3YS5oaXJvYWtpQHN5bmVyZ3kxMDEuanAiLCJnaXZlbl9uYW1lIjoi55m95bed5aSn5pyXIiwiZW1haWwiOiJzaGlyYWthd2EuaGlyb2FraUBzeW5lcmd5MTAxLmpwIn0.FgSMLzt3qRRI7i6paCATZWQIw6fChWwwooYeH4skcXHrSeuV2Ovhn1oWwtet6gme9j8kaVjv0BsDCjJzfeFFcPrVVv3O_sj4OLvCZ7Y_u2H51XfaqqB_gAwukutxbCBF6eoNdsFguoHHEdQ_ikmxxSm5-s68BA74xU3VTa65_FNYOXARlAj28GDlpf6A_Fo6xfLJ19cwbhGlsoroNu_IFoI18dZfx3bDnFQ2L7XPzsNw_XHT31YlDXjnegBzc-E_tSAqKYV8sur6hi2p3Hg7z_eH0GpaGScYRgGu6iDMXlRFXO3hJNqcd7uQ4332xDTaXlnmVPdhdSVIumTgFsl3CQ", "expires_in": 60, "refresh_expires_in": 1800, "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICIyNTFjMWNlNy1kZjNjLTRiNDYtOTRiNC04MWVlN2M4MGYyYWUifQ.eyJqdGkiOiJiM2RhNGE0NC0wZGU5LTRkNzMtYjcxNi0xYjE2ZWRkYzQwNWMiLCJleHAiOjE1Njc1MDY1MjcsIm5iZiI6MCwiaWF0IjoxNTY3NTA0NzI3LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvbWFzdGVyIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL2F1dGgvcmVhbG1zL21hc3RlciIsInN1YiI6ImMwNDIwNWQxLTBlNmMtNDFjMy04NTkzLTFkYmZhNGZkZjdlMyIsInR5cCI6IlJlZnJlc2giLCJhenAiOiJ0YXJnZXRjbGllbnQiLCJhdXRoX3RpbWUiOjAsInNlc3Npb25fc3RhdGUiOiIyOTEyNmVkZC1kMmVmLTQ5MDEtOTc1OS03YzUzNTMxZTgzYjgiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoicHJvZmlsZSBlbWFpbCJ9.FRiA5EZwZpX8Vopqts8RH1l5NaPFYDlnQaSoPvkvoOc", "token_type": "bearer", "not-before-policy": 0, "session_state": "29126edd-d2ef-4901-9759-7c53531e83b8", "scope": "profile email" } |
発行されたアクセストークンのペイロード部分はこんな感じです。
先述した act
クレームや、may_act
クレームがペイロードの中に現れてこないですね。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
{ "jti": "b2a52ec1-c165-444b-8ca4-6c904557f286", "exp": 1567504787, "nbf": 0, "iat": 1567504727, "iss": "http://localhost:8080/auth/realms/master", "aud": "account", "sub": "c04205d1-0e6c-41c3-8593-1dbfa4fdf7e3", "typ": "Bearer", "azp": "targetclient", "auth_time": 0, "session_state": "29126edd-d2ef-4901-9759-7c53531e83b8", "acr": "1", "realm_access": { "roles": [ "offline_access", "uma_authorization" ] }, "resource_access": { "account": { "roles": [ "manage-account", "manage-account-links", "view-profile" ] } }, "scope": "profile email", "email_verified": false, "name": "白川大朗", "given_name": "白川大朗", } |
act
クレームや、may_act
クレームがペイロードの中に現れていない理由ですが、Keyclock のサービスガイド (原文) の以下引用にある通り、 OAuth Token Exchange の仕様を無視しているためだと考えられます。
KeycloakのToken Exchangeは、 OAuth Token Exchange の仕様の非常にルーズな実装です。
Keycloakでは、それを少し拡張し、一部を無視し、仕様の他の部分をルーズに解釈しました。
終わりに
OAuth 2.0 Token Exchange 自体がドラフトである点、また Keycloak が実装している仕様は OAuth Token Exchange の仕様の一部を無視したりしている点から、実用レベルにはまだまだ遠い印象です。
現状でこのような各クライアントからの権限委任を行なう場合は、何らかの方法でアクセストークンの交換の仕組みを自前で実装するしかないでしょう。
しかし、外部で発行されたアクセストークンを内部向け API 用に交換できるのは魅力的なので、これから議論が進み仕様が標準化されて、様々な OAuth / OpenID Connect のプロダクトで実装されるようになることを期待したいと思います。
最後に理解の助けとなる参考文献を載せておきます。