OAuth
oauth2
Hydra

RFC7662: OAuth 2.0 Token Introspectionでアクセストークンの検証を行う

はじめに

API認証にOAuth2を使う場合、認可サーバでアクセストークンを発行して、リソースサーバ(APIサーバ)にアクセストークン付きでリソースをリクエストするわけですが、認可サーバとリソースサーバが分かれてる場合、アクセストークンをリソースサーバで受け取ったあとに、このトークンは正しいのか?どのようなスコープが許可されているのか?どうやって確認すればよいんだろうかという疑問が湧いてきます。

OAuth2そのものの「RFC6749: The OAuth 2.0 Authorization Framework」では仕様の範囲外として記載されていませんが、調べたところ、OAuth2の拡張仕様で、以下の選択肢があるようです。

RFC7662は認可サーバのトークン確認用エンドポイントにリクエストを送信し、レスポンスとして検証結果をもらう方法です。リソースサーバから見ると、検証に必要なロジックを知る必要がないので、実装が簡単ですが、ネットワーク経由の呼び出しになるので、パフォーマンスが気になる場合は、トークンの期限などを考慮してキャッシュしたりする必要があるかもしれません。

また、特に標準化されてはいないですが、OAuth2のコアからすると、アクセストークンはどういうロジックで生成しても構わないので、アクセストークンの文字列そのものにスコープなどの必要な情報を含むRFC7519: JSON Web Token (JWT)形式のトークンを発行する方法もあります。JWTはJSON形式のデータに署名することで改竄防止を行うので、事前にリソースサーバが署名に使われた認可サーバの公開鍵を取得しておけば、アクセストークンの検証がローカルで可能です。ただし、JWTは標準化されていますが、OAuth2のアクセストークンにJWTを使う事自体は標準化されていないので、認可サーバの実装依存であることに注意が必要です。

(※2017/11/24 補足:当初 RFC7523: JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization GrantsをJWT形式のアクセストークンを発行する仕様だと勘違いしてましたが、RFC7523はアクセストークンを取得する際にJWT Bearer Tokenを使うというだけで、JWT形式のアクセストークンを発行する仕様ではありませんでした。 @wadahiro さん指摘ありがとうございました)

ここでは、RFC7662: OAuth 2.0 Token Introspectionを試してみます。

RFC7662: OAuth 2.0 Token Introspectionを試す

検証のためOAuth2のproviderとして ory/hydra を使います。
hydraのバージョンは0.9.12です。また稼働確認は便宜上httpでやってますが、実際に使う場合はhttpsを使いましょう。

hydra自体の使い方は以前簡単な説明を書いたので、こちらも参考にしてみて下さい。
ory/hydraで OAuth2.0 / OpenID Connect Provider を立ててみる

$ git clone https://github.com/ory/hydra
$ cd hydra
[hydra@master]$ git rev-parse HEAD
f4176a6f61b06aeb320cca7938f788fbfb42add8

検証用の認可サーバを起動します。

[hydra@master]$ docker-compose up

もう1枚ターミナルを開いて、hydraのコンテナにアタッチします。

[hydra@master]$ docker exec -it hydra_hydra_1 /bin/sh
/go/src/github.com/ory/hydra #

APIを呼び出すクライアントを登録します。
ここではクライアントクレデンシャルズフローを使うので、 --grant-types client_credentials とし、
適当なスコープ hoge.fuga:read hoge.piyo:write を許可します。

/go/src/github.com/ory/hydra # hydra clients create \
  --id myapp \
  --secret myapp-secret \
  --name MyApp \
  --grant-types client_credentials \
  --response-types token \
  --allowed-scopes "hoge.fuga:read hoge.piyo:write"

You should not provide secrets using command line flags. The secret might leak to bash history and similar systems.
Client ID: myapp
Client Secret: myapp-secret

リソースサーバ(APIサーバ)用のクライアントを登録します。
アクセストークンを検証するだけなので、スコープは不要です。

/go/src/github.com/ory/hydra # hydra clients create \
  --id api \
  --secret api-secret \
  --name API \
  --grant-types client_credentials \
  --response-types token

You should not provide secrets using command line flags. The secret might leak to bash history and similar systems.
Client ID: api
Client Secret: api-secret

もう1枚ターミナルを開いて、
curlでAPIクライアントのclient_id/client_secretを使って、トークンエンドポイントにリクエストを送信し、アクセストークンを発行してみます。
また、認可サーバのIPアドレスは先ほどdocker-composeで起動したものなので、適宜読み替えて下さい。

[hydra@master]$ curl -v -X POST \
  -u "myapp:myapp-secret" \
  -d "grant_type=client_credentials&scope=hoge.fuga:read hoge.piyo:write" \
  http://192.168.99.100:4444/oauth2/token

*   Trying 192.168.99.100...
* Connected to 192.168.99.100 (192.168.99.100) port 4444 (#0)
* Server auth using Basic with user 'myapp'
> POST /oauth2/token HTTP/1.1
> Host: 192.168.99.100:4444
> Authorization: Basic bXlhcHA6bXlhcHAtc2VjcmV0
> User-Agent: curl/7.43.0
> Accept: */*
> Content-Length: 66
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 66 out of 66 bytes
< HTTP/1.1 200 OK
< Cache-Control: no-store
< Content-Type: application/json;charset=UTF-8
< Pragma: no-cache
< Date: Tue, 10 Oct 2017 05:35:51 GMT
< Content-Length: 187
<
* Connection #0 to host 192.168.99.100 left intact
{"access_token":"CvAopBZ_v_V-chA6XaanfqEhs0j2olkZwCcKIOwbSDo.DoYsSDP3TGiHclLaBAHc0ngVHtJdBSLI7RPYSwTkL2c","expires_in":3599,"scope":"hoge.fuga:read hoge.piyo:write","token_type":"bearer"}%                                                                                            [hydra@master]$

アクセストークンが発行されました。

token_typeはbearerになっています。このトークンをAPIクライアントから、APIサーバに渡す場合は、RFC6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage に従い、HTTPヘッダで Authorization: Bearer (アクセストークン) のように渡しますが、ここでは省略します。

アクセストークンがAPIサーバまで渡ってきたとして、このトークンをRFC7662: OAuth 2.0 Token Introspectionで検証してみます。

curlでAPIサーバのclient_id/client_secretを使って、トークン確認エンドポイントにリクエストを送信し、アクセストークンを検証してみます。トークン確認エンドポイントは、この例では、 /oauth2/introspect になっていますが、仕様上定義はされていないので、事前になんらかの方法でエンドポイントのURLを入手しておく必要があります。POSTリクエストの中身はtokenのみRFC7662で定義されている必須パラメータです。

[hydra@master]$ curl -v -X POST \
  -u "api:api-secret" \
  -d "token=CvAopBZ_v_V-chA6XaanfqEhs0j2olkZwCcKIOwbSDo.DoYsSDP3TGiHclLaBAHc0ngVHtJdBSLI7RPYSwTkL2c" \
  http://192.168.99.100:4444/oauth2/introspect

*   Trying 192.168.99.100...
* Connected to 192.168.99.100 (192.168.99.100) port 4444 (#0)
* Server auth using Basic with user 'api'
> POST /oauth2/introspect HTTP/1.1
> Host: 192.168.99.100:4444
> Authorization: Basic YXBpOmFwaS1zZWNyZXQ=
> User-Agent: curl/7.43.0
> Accept: */*
> Content-Length: 93
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 93 out of 93 bytes
< HTTP/1.1 200 OK
< Content-Type: application/json;charset=UTF-8
< Date: Tue, 10 Oct 2017 06:04:44 GMT
< Content-Length: 169
<
{"active":true,"scope":"hoge.fuga:read hoge.piyo:write","client_id":"myapp","sub":"myapp","exp":1507617351,"iat":1507613750,"aud":"myapp","iss":"http://localhost:4444"}
* Connection #0 to host 192.168.99.100 left intact

"active":true となっており、アクセストークンが有効であることが分かりました。レスポンスはRFC7662としては、activeのみ必須ですが、scopeなどいくつかOPTIONALで補足情報が返ってきます。scopeは返ってこないと厳しい。

ちなみにhydraではリクエスト時にscopeに許可されていないスコープを指定すると、

[hydra@master]$ curl -v -X POST \
  -u "api:api-secret" \
  -d "token=CvAopBZ_v_V-chA6XaanfqEhs0j2olkZwCcKIOwbSDo.DoYsSDP3TGiHclLaBAHc0ngVHtJdBSLI7RPYSwTkL2c&scope=hoge" \
  http://192.168.99.100:4444/oauth2/introspect

*   Trying 192.168.99.100...
* Connected to 192.168.99.100 (192.168.99.100) port 4444 (#0)
* Server auth using Basic with user 'api'
> POST /oauth2/introspect HTTP/1.1
> Host: 192.168.99.100:4444
> Authorization: Basic YXBpOmFwaS1zZWNyZXQ=
> User-Agent: curl/7.43.0
> Accept: */*
> Content-Length: 104
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 104 out of 104 bytes
< HTTP/1.1 200 OK
< Content-Type: application/json;charset=UTF-8
< Date: Tue, 10 Oct 2017 06:10:35 GMT
< Content-Length: 17
<
{"active":false}
* Connection #0 to host 192.168.99.100 left intact

"active":false が返って便利ですが、リクエスト時のscopeはRFC7662で定義されているパラメータではないです。
一方レスポンスのscopeフィールドはOPTIONALですが、フィールド名は仕様上記載されているので、標準化の範囲を意識するなら、scopeに含まれるかの判定はAPIサーバ側でレスポンスを解析した方がよいかなと思います。

所感

  • RFC7662: OAuth 2.0 Token Introspectionを試してみた。
  • OAuth2の拡張仕様なので、実装されているかはOAuth2プロバイダ実装次第ではあるが、認可サーバとリソースサーバの実体が異なるユースケースを想定すれば、一般的に必要な機能だと思う。
  • とりあえずHTTPリクエストが投げられればよいだけなので、パフォーマンスがシビアでなければAPIクライアント/APIサーバ側の実装は簡単そう。
  • パフォーマンスとかを考えるとキャッシュしたりとか考え出すと実装は多少複雑になりそう。