JWT+RDBを用いたOAuth 2.0 ハイブリッド型トークンの実装例

おはようございます、ritouです。

(⚠️認可イベントの識別子のあたり、ちょっと見直しました!最初に見ていただいた方はもう一回どうぞ!)

f:id:ritou:20200507101740p:plain

前回、ハイブリッド型と呼ばれる OAuth 2.0 のトークン実装について書きました。

ritou.hatenablog.com

その続きとして JWT(JWS) + RDBでできる実装例を紹介します。 理解するにはそれなりの OAuth 2.0 に関する知識が必要になるかもしれませんが、よかったら参考にしてみてください。

何を考えたのか

OAuth 2.0のRefresh Token, Access Tokenを考えます。 要件から整理しましょう。

要件

結構ありますが、最低限の OAuth 2.0 の Authorization Server を実装しようと思ったらこれぐらいはやらないといけないでしょう。

  • RFC6750 で定義されている Bearer Token 相当
  • Refresh Token
    • grant_type=authorization_code で発行される
    • grant_type=refresh_token で自身が更新される、いわゆるローテートにも対応
  • Access Token
    • grant_type=authorization_code, grant_type=refresh_token で発行される
    • Refresh Token からのダウンスコーピング(scopeを減らして指定)が可能
  • トークンから参照できる情報 : 色々とセンシティブな金融関係のAPIを利用するためのトークンなどではなく、Clientからこれらの内容は見れても良い想定
    • Resource Owner's identifier
    • Authorization Request(client_id, scope etc)
  • 無効化
    • Resource Owner + Client 単位で現在発行中の Refresh/Access Token を無効化可能 : Resource Owner が自ら無効化できる想定
    • 個別の認可イベント単位でも無効化可能 : ここまでやらなくても良さそうではあるけど、一応できるようにしておく。
    • 個別の Access Token 単体での無効化まではしない
  • Resource Server の Access Token ハンドリング
    • Token Introspection API(もしくは独自の仕組み)で検証可能
    • 有効期限前に無効化されていることを許容できるならばResource Server自体で検証可能

このような要件を、DBにテーブルいっぱい作ってやるんじゃなくJWTの特徴を生かしてなんとかできないか、というのがこの記事で紹介する実装の目的です。

データの持ち方

JWT部分は仕様策定中のこのDraftを参考にしてます。

tools.ietf.org

JWT(JWS)

  • Header
    • typ : Access Token は at+jwt, Refresh Token は rt+jwt (独自)
    • alg : RSXXX / ESXXX
    • kid : 指定あり。公開鍵は jwks_uri などで共有
  • Payload
    • iss : Authorization Serverの識別子
    • aud : Refresh Token なら Authorization Server, Access Token なら Resource Server の識別子
    • sub : Resource Owner の識別子
    • client_id : Client ID
    • scope : 認可イベントで処理された scope の値
    • iat : 発行日時
    • exp : 発行日時にRefresh Tokenは長め、Access Tokenは短めの時間を追加した有効期限
    • auth_id : Refresh Token は認可イベント(Resource Owner がリソースアクセスを許可するタイミング)単位でユニークな identifier (UUID/ULID的なの)を auth_id として持つ。Access Tokenは同時に発行された、もしくは更新された Refresh Token の auth_id の値を持つ。
  • アクション
    • RDBの create / update の後にRefresh Token/Access TokenのJWT生成

⚠️jti ではなく auth_id として認可イベントの識別子を持つように変更しました。

RDB

  • カラム
    • id : JWT の auth_id と同じ値(もしくはハッシュ値) プライマリーキー
    • client_id : JWT の client_id と同じ値 : 無効化の時に使う。
    • user_id : JWT の sub と同じ値 : 無効化の時に使う。
    • updated_at : Refresh Token の発行(最終更新)日時。ローテートの時に使う。
  • アクション
    • create : 認可イベントで作成される
    • read : grant_type=refresh_tokenの時や、Token Introspection API(もしくは独自の仕組み)が呼ばれた時、あとはResource Onwer に無効化機能を提供する時に参照
      • grant_type=refresh_token では auth_id, updated_at の組み合わせで検索することでローテートされた最新の Refresh Token のみ扱うことが可能
    • update : RefreshTokenのローテートのタイミングで updated_at を更新
    • delete : Resource Owner 自身による無効化などのタイミングで消す。updated_at の値を見て Refresh Token の有効期限を超えたものは処理の途中や定期的に消す。

ポイントは認可イベントが行われた時にのみ生成されるというあたりです。 Access Token 単位でデータを持つと規模によってはデータ量が増えるわけですが、RDBが持つデータ数を有効なRefreshTokenの数に留めることが可能です。

RDBの中身とJWTはこんな感じになります。

# grant_type=authorization_code

## RDB

* id : 10870e51-e06a-03cb-33c7-eca6631d8e3d
* user_id : "12345"
* client_id : "cid_abcde"
* updated_at : 1588788749

## Refresh Token
eyJhbGciOiJSUzI1NiIsImtpZCI6Im9hdXRodG9rZW4yMDIwMDUiLCJ0eXAiOiJydCtqd3QifQ.eyJhdWQiOiJodHRwczovL2F1dGhvcml6YXRpb24tc2VydmVyLmV4YW1wbGUuY29tLyIsImF1dGhfaWQiOiIxMDg3MGU1MS1lMDZhLTAzY2ItMzNjNy1lY2E2NjMxZDhlM2QiLCJjbGllbnRfaWQiOiJjaWRfYWJjZGUiLCJleHAiOjE1ODg4NzQyNDksImlhdCI6MTU4ODc4Nzg0OSwiaXNzIjoiaHR0cHM6Ly9hdXRob3JpemF0aW9uLXNlcnZlci5leGFtcGxlLmNvbS8iLCJzY29wZSI6Im9wZW5pZCBzYW1wbGUgc2FtcGxlMiIsInN1YiI6IjEyMzQ1In0.Op26WcEUS2qG64KMGT2a4EyLziEhta5gcTGIeE4uu2sw9PC9mxoQo18iBNcaAk34n4OKEH1Qi8lzfb6fWMySEohqWKjv-iZtuBfu0O550z1faCx3jhoNqoSwKInMbOJHR8PtJN5E_GEZTNFwH3i4dEk1Moi8KAX8aJ7OZBR3yFipnbb6AC3Vpy0JuLxZMJjU_EVfO3XOZ2YA9OYlpFerflnPUTpPIE1OV5yQ7ecvnPDpShpVS3A-s7n3iZMp7z6kxvkYIsZc0W5JKxrfN-ivkPHaTeKluxM0eOmXV_Q6hmH7U2S0gf6iJB76UOCsw7VtqsTnANLCRI-rBJAgKTu9vg

## Access Token
eyJhbGciOiJSUzI1NiIsImtpZCI6Im9hdXRodG9rZW4yMDIwMDUiLCJ0eXAiOiJhdCtqd3QifQ.eyJhdWQiOiJodHRwczovL3JzLmV4YW1wbGUuY29tLyIsImF1dGhfaWQiOiIxMDg3MGU1MS1lMDZhLTAzY2ItMzNjNy1lY2E2NjMxZDhlM2QiLCJjbGllbnRfaWQiOiJjaWRfYWJjZGUiLCJleHAiOjE1ODg3ODg3NDksImlhdCI6MTU4ODc4Nzg0OSwiaXNzIjoiaHR0cHM6Ly9hdXRob3JpemF0aW9uLXNlcnZlci5leGFtcGxlLmNvbS8iLCJzY29wZSI6Im9wZW5pZCBzYW1wbGUgc2FtcGxlMiIsInN1YiI6IjEyMzQ1In0.v0J_KCws4qQBNYiuWBnqi1Pq0L34_URj4vLnuXXwegdyF087SsgI0j0BTM0Rx9LjgYn4JTmwRKYkNnC7vTW3xpvteXmvQEUVJpMPIfYulwz_oQKOPuB1Nr259Q8d9Bcu7Tlr5skyVjIqya1O6VYEkI7YHBCD2MVh0V6L0pWcS7JazkSzaaS50MdGqDWD7uafLSjdzI52tQfhCiyhhnfi8glU5byg1s3g5hQrXx7hhNqOfECBteySUaVE43W3SVHv2pRlJ-XPla0fohnHtzFqodhPCJ4BuiTU7nh7bzVCeAVfxUZCNitA8oj0SiM9lHjbXtEkqnIXa4JZsJSYFjBLgg

f:id:ritou:20200507231345p:plain

f:id:ritou:20200507230909p:plain

# grant_type=refresh_token

## RDB

* id : 10870e51-e06a-03cb-33c7-eca6631d8e3d
* user_id : "12345"
* client_id : "cid_abcde"
* updated_at : 1588788750 <- updated!!!

## Refresh Token(rotated)
eyJhbGciOiJSUzI1NiIsImtpZCI6Im9hdXRodG9rZW4yMDIwMDUiLCJ0eXAiOiJydCtqd3QifQ.eyJhdWQiOiJodHRwczovL2F1dGhvcml6YXRpb24tc2VydmVyLmV4YW1wbGUuY29tLyIsImF1dGhfaWQiOiIxMDg3MGU1MS1lMDZhLTAzY2ItMzNjNy1lY2E2NjMxZDhlM2QiLCJjbGllbnRfaWQiOiJjaWRfYWJjZGUiLCJleHAiOjE1ODg4NzQyNTAsImlhdCI6MTU4ODc4Nzg1MCwiaXNzIjoiaHR0cHM6Ly9hdXRob3JpemF0aW9uLXNlcnZlci5leGFtcGxlLmNvbS8iLCJzY29wZSI6Im9wZW5pZCBzYW1wbGUgc2FtcGxlMiIsInN1YiI6IjEyMzQ1In0.STXzXd-1x7mT44PosBAGTF46yZ3ZtrfSDhC-6kNYQYRc0bwpHuNEFlFCQkJiX4GS8pCrygoovuqPjPHL4mp68Rebw5hliUJZ6iWGhPpbiSd7JCSXDrXPiJe0hYevgoksfqeGknHp6yiSH0E6KmjZoO7BpZVJr4gcBCC9eeqP4f_TsY7k3m4U_zpUdeyakvqVNfefn3WPzMymxiP-xvH60mbea7HQJXi_dkOweVECLrNEPGoWwkiHAGXaC6GKHs-7Ime1nJF3j6WDYEIzJDtqFkQS_Y14Ps6EgWyFNQegcBMbhc0-LQnFpxqD4e2VEqQPhUwMsPtQhQZDNA2OUbOiAA

## Access Token(down scoped)
eyJhbGciOiJSUzI1NiIsImtpZCI6Im9hdXRodG9rZW4yMDIwMDUiLCJ0eXAiOiJhdCtqd3QifQ.eyJhdWQiOiJodHRwczovL3JzLmV4YW1wbGUuY29tLyIsImF1dGhfaWQiOiIxMDg3MGU1MS1lMDZhLTAzY2ItMzNjNy1lY2E2NjMxZDhlM2QiLCJjbGllbnRfaWQiOiJjaWRfYWJjZGUiLCJleHAiOjE1ODg3ODg3NTAsImlhdCI6MTU4ODc4Nzg1MCwiaXNzIjoiaHR0cHM6Ly9hdXRob3JpemF0aW9uLXNlcnZlci5leGFtcGxlLmNvbS8iLCJzY29wZSI6Im9wZW5pZCBzYW1wbGUiLCJzdWIiOiIxMjM0NSJ9.YaX5u_Ef174aoC7QP48twCZxBHOxGELXD0k-e8aRR7W8c6IcPlUrWnZqQzQahLGm0Cjm7P6fnH7I4FJITiOfQAlWY3t-xqUYILngATs6TJ-gIhBd2W-e4uHQ7S98cVyCS_jI-gz_QnMHOaALz_RILvX_bNTsgZ1XSvGyRzRl5zQ_fagU-0QCbEyma6HYonJ_ua6i_QHzW4WzJ7haOut-E7i0kqv-7EI4EEaC2bf-c8xEqP51scHB1VikrBZ97_iMGmCUzLJ5qAwGShB3Z2bZvbaaGrnNxuCKvV04ayl-KhE6iyIpqPP7Tz8CXy-NnboaYI8gaYmZ8K4yYMqIzQlDuA

f:id:ritou:20200507233134p:plain

f:id:ritou:20200507233447p:plain

まとめ

OAuth 2.0 の Refresh Token / Access Token を JWT + RDB で実装する例を紹介しました。 ベタに UUID/ULID を各種トークンに使用するのに比べて

  • JWTを利用することでResource Server - AuthZ Sever 間の無駄な通信を抑えられる
  • RDB側は最低限のデータ保持で済む
  • Refresh Tokenのローテートにもデータを増やさずに対応可能

という特徴があります。 誰かのお役に立てれば幸いでございます。

社会復帰頑張りましょう。 ではまた。