OAuthのハイブリッド型アクセストークン実装に関するあれこれ

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

f:id:ritou:20200503025039p:plain

先日オンラインで開かれたAuthlete社の勉強会で、アクセストークンの実装パターンについて触れられていました。

www.authlete.com (titleはもうちょっとなんとかする方が良さそう)

ドキュメントでいうとこの辺りでしょうか。 qiita.com

  • 識別子型
  • 内包型
  • ハイブリッド型

の3種類があると説明されていました。 この中でハイブリッド型の中にも色々あるよなってところで、ちょっと自分の思うところを書いてみようと思います。

上記ドキュメントのハイブリッド型の説明

説明としては

ハイブリッド型の実装方法では、内包型アクセストークンを生成しつつも、それに付随するデータを認可サーバーのデータベース内に持ちます。

という感じで、クライアントから隠蔽すべき情報の扱いとして、

ハイブリッド型アクセストークンを利用し、秘密にしたい情報をアクセストークンには含めず、サーバー側のデータベース内のみに保存する。

とあります。

今回取り上げる実装パターン

こんな感じの2種類の実装パターンを考えます。

  • 識別子のみを内包
  • イントロスペクト可能な内包型

これらはどちらも上記の動画や資料にあるハイブリッド型の範囲に収まっており、どこまで情報を持つかの違いですね。

識別子のみを内包

最低限の情報しか含まない実装です。 Payloadの例はこんな感じになるでしょう。

   {
     "jti": "abcdefghijklmn01234567890",
     "iss": "https://authorization-server.example.com/",
     "aud": "https://rs.example.com/",
     "exp": 1544645174
   }

特徴としては

  • 識別子型 + 最低限のメタデータ + 署名
  • "sub" とかも含まない。データを引くときは "jti" を使って参照する
  • RSによる検証のために "iss", "aud", "exp" を残している

というところです。

識別子型での実装の場合、RSからトークンイントロスペクションなり独自の仕組みでの問い合わせが毎回発生します。 それはしょうがないんでしょうけれども、"hogehogehogehoge" みたいな文字列とかで問い合わせを行うのはもったいないです。 そして有効期限の切れているアクセストークンを弾きたい時も、タイムスタンプ機能を加えたアクセストークン文字列の設計が必要となるでしょう。 そこで、最低限のメタデータと一緒にJWTにしちゃって、諸々の検証によって無駄な問い合わせを軽減しようってのがこの実装です。

次行きましょう。

イントロスペクト可能な内包型

これは上記資料にある内包型+α的な実装です。

   {
     "jti": "abcdefghijklmn01234567890",
     "iss": "https://authorization-server.example.com/",
     "sub": " 5ba552d67",
     "aud":   "https://rs.example.com/",
     "exp": 1544645174,
     "client_id": "s6BhdRkqt3_",
     "scope": "openid profile reademail"
   }
  • Clientから見えて良い情報を内包しちゃう
  • "jti" を使って生存確認ができる

こちらもトークンイントロスペクションもしくは独自の方法で問い合わせを"行える"前提ですが、必須にする必要はないだろうというお話です。

  • そんなにセンシティブな情報を扱わない RS であれば手元の検証だけでOK
  • センシティブな情報を扱うRSの場合、手元の検証 + 問い合わせまでやる

前者はプロフィール画像などほぼ公開されているデータを返すAPI、後者は決済系のAPIなどですね。 個人的には、APIでこれらが混在しても、そんなに大きな問題は起こらないと思っており、最初から「(識別型、内包型、ハイブリッドの)3種類のうちどれ選ぶ!?!?」となる必要はないんだよと思っています。

まとめ

  • ハイブリッド型と呼ばれる実装の中で、内包されるデータが微妙に異なる2種類に注目した
  • 識別子のみを内包し、検証によって無駄な通信を削減するのはどうだろう
  • 識別子の問い合わせは用意しつつ、必須にしないという実装もありそう
  • eyJ! eyJ!

というお話でした。まぁ、細けぇ話ですね。

ではまた。

おまけ

そういえば、6年ぐらい前から同じようなこと言ってました。

ritou.hatenablog.com

しかし見直すと色々怪しいですね。

f:id:ritou:20200503031212p:plain

この記事の例だとユーザー識別子を "sub" で持っていますが、いわゆる "OAuth as a Authentication" に囚われていたのかもしれません。 あくまでOAuthはリソースアクセスすることが目的なので、発行対象のユーザー識別子を必ずしも含んでClientが(見ようと思えば)見れる必要はない、というのが今の考えです。

そして "aud" の値が Client ID になっています。 OIDCのID TokenはClientが検証するためのものなのでこれでいいんですが、OAuthのAccessTokenだと "aud" はアクセストークンを受け付ける Resource Server の値を含むべきでしょう。

今後もぼくのかんがえるさいきょうのおーおーすとーくんせっけいを考えていきたいと思います。

OIDC CIBAのようなDecoupled AuthZ/AuthNプロトコルでリスクベース判定したくない?

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

f:id:ritou:20200502011658p:plain

OAuth 2.0 の Device Flow(RFC 8628) や OpenID Connect Client Initiated Backchannel Authentication Flow(いわゆるCIBA)、XYZ/XAuthといった次のOAuth候補みたいなプロトコルでは次のような流れがサポートされています。

  • ClientがIdPに認可リクエストをバックチャンネルで送る
  • IdPがユーザーインタラクション用のURLなどを返して Client がユーザーをそこに誘導したり、専用のアプリに通知を送ったりして認証、リソースアクセス等に同意する

いわゆるベーシックなOIDCの認可コードフローでは "Client が動作する環境" と "IdPとユーザーが対話を行う環境" が同一であることが想定されるわけですが、このような流れにすることで "Client が動作する環境" と "IdPとユーザーが対話を行う環境" が別であるケースもサポートできます。

今回はこれらのプロトコルで IdP がリスクベース認証を入れようと思った時に、必要となるパラメータについてのお話です。

"Client が動作する環境" と "IdPとユーザーが対話を行う環境" が同一の場合

いわゆる認可(認証)リクエストってので

  • WebApp な Client がリダイレクトで IdP にユーザーを送る
  • NativeApp な Client が外部ブラウザを立ち上げて IdP にユーザーを送る

という場合、 IdP が自身のエンドポイントへのアクセスを受けた時点のブラウザやデバイスの情報を収集、分析して追加の認証や再認証を要求したり、認証をスキップさせることができるでしょう。

ブラウザが一瞬開いて...みたいな挙動は古き悪きリワード広告みたいでいけんのか?とは思いますが、とりあえず。

"Client が動作する環境" と "IdPとユーザーが対話を行う環境" が別の場合

それに対して、それぞれの環境が別な場合、プロトコルを単純に実装しただければ簡単ではなさそうです。

オンライン決済にCIBAを適用する例を考えてみましょう。

  1. ブラウザのECサイトになんかのIDを突っ込む
  2. 手元のスマホに「ECサイト名」「金額」とかが通知されて「OK」する
  3. ブラウザも決済完了になって終わり

こんなことができるわけですが、1 と 3 を行うブラウザが Consumption Device (CD), 2 を行うスマホが Authentication Device (AD) となります。 物理的に同じ場所にいても、デバイス的には別です。

例えば、「いつも使ってるブラウザかつお気に入りのECサイトなら2の処理をスキップ」なんてことを実装しようと思うとどうでしょう。 ブラウザ情報をどこかで送る必要があります。それはいつでしょうか? ユーザーインタラクションをコントロールするためには、認可(認証)リクエストをバックチャンネルで送るところに含む必要があるでしょう。 この辺りはOAuth 2.0 Rich Authorization Requestsなんかを使って複雑なデータ表現が可能になります。

tools.ietf.org

では、どんな情報を送ったら良いでしょうか。 そもそもこんなの既にどこかでやられてるのに違いないということで、3D Secure 2.0を参考にしてみましょう。

3D Secure 2.0のリスクベース認証の仕組み

この辺りをざっくりと理解するためにちょうど良いドキュメントがあります。

stripe.com

3D セキュア 2 は、企業がオンラインでのクレジットカード決済を安全に認証できるようにするためのセキュリティ規格です。3D セキュア 2 (3DS2) について詳しくご説明します。

とか書いてますが中身英語です。”Frictionless Authentication” ってとこに書いてあります。

3D Secure 2 allows businesses and their payment provider to send more data elements on each transaction to the cardholder’s bank. This includes payment-specific data like the shipping address, as well as contextual data, such as the customer’s device ID or previous transaction history. If the data is enough for the bank to trust that the real cardholder is making the purchase, the transaction goes through the “frictionless” flow and the authentication is completed without any additional input from the cardholder.

いろんなデータを送っているようです(ざっくり)。

これをOIDCで扱うデータに置き換えてみると

  • Clientはデバイス(またはセッション、あるいはその両方)の情報をAuthNリクエストに追加する
  • IdPは受け取った情報からリスクを判断し、それが低い場合は対話をスキップしても良い

って感じにできそうです。

次はデータの内容ですが、リスクベース認証のために3Dセキュア2.0で送信されるデバイス情報は、EMV® 3-D Secure SDK — Device Informationとして定義されています。

具体的には

というあたりのデータが、仕様に準拠していることを認定されたSDKを使用することで取得、送信されます。

# Device Info for Android (from spec)
{
"DV":"1.0", # DV: Data Version
"DD":{"C001":"Android","C002":"HTC One_M8","C004":"5.0.1","C005":"en- US","C006":"Eastern Standard Time","C007":"06797903-fb61-41ed-94c2-4d2b74e27d18","C009":"John's Android Device",....}, # DD: Device Data
"DPNA":{"C010":"RE01","C011":"RE03"}, # DPNA: Device Parameter Not Available
"SW":["SW01","SW04"] # SW: Security Warning. For information about Security Warning, refer to the EMV 3-D Secure SDK Specification.
}

Webブラウザーベースの場合、3D Secure 2.0の仕様で定義された値のリストがあります。

  • Browser Accept Headers
  • Browser IP Address
  • Browser Java Enabled
  • Browser Language
  • Browser Screen Color Depth
  • Browser Screen Height
  • Browser Screen Width
  • Browser Time Zone
  • Browser User-Agent

ということで、全部含めたら結構な量になりそうですが、このアプローチはOIDCにも適用できそうです。

OIDCの認証リクエストにデバイス情報を追加する拡張案

比較的大きなデバイス情報を送る必要がありそうですが、既にOIDC/OAuth 2.0で使われている方法が適用できるでしょう。

  • JWTにシリアライズした値 (OIDC の “request” パラメータ)
  • JSON もしくは JWT をホストするURI (OIDC の “request_uri” パラメータや OAuth 2.0 の PAR)

あんまりこういう方式をネストしたくはない気もしますが、デバイス情報の場合は

  • “device_info”
  • “device_info_uri

というパラメータを新規に用意することで実現可能となるでしょう。 この時、JWTペイロードまたはJSON本文には、デバイス情報が含まれています。

# JWT's Payload and device_uri response
{
 "platform":"Android",
 "device_model":"HTC One_M8",
 "os_version":"5.0.1",
 ...
 "device_name":"John's Android Device",
 ...
}

CIBAの認証リクエストに含む場合、こんな感じになるでしょう。

POST /bc-authorize HTTP/1.1
   Host: server.example.com
   Content-Type: application/x-www-form-urlencoded

scope=openid%20email%20example-scope&
client_notification_token=8d67dc78-7faa-4d41-aabd-67707b374255&
binding_message=W4SCT&
login_hint_token=eyJraWQiOiJsdGFjZXNidyIsImFsZyI6IkVTMjU2In0.eyJ
zdWJfaWQiOnsic3ViamVjdF90eXBlIjoicGhvbmUiLCJwaG9uZSI6IisxMzMwMjg
xODAwNCJ9fQ.Kk8jcUbHjJAQkRSHyDuFQr3NMEOSJEZc85VfER74tX6J9CuUllr8
9WKUHUR7MA0-mWlptMRRhdgW1ZDt7g1uwQ&
client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3A
client-assertion-type%3Ajwt-bearer&
client_assertion=eyJraWQiOiJsdGFjZXNidyIsImFsZyI6IkVTMjU2In0.eyJ
pc3MiOiJzNkJoZFJrcXQzIiwic3ViIjoiczZCaGRSa3F0MyIsImF1ZCI6Imh0dHB
zOi8vc2VydmVyLmV4YW1wbGUuY29tIiwianRpIjoiYmRjLVhzX3NmLTNZTW80RlN
6SUoyUSIsImlhdCI6MTUzNzgxOTQ4NiwiZXhwIjoxNTM3ODE5Nzc3fQ.Ybr8mg_3
E2OptOSsA8rnelYO_y1L-yFaF_j1iemM3ntB61_GN3APe5cl_-5a6cvGlP154XAK
7fL-GaZSdnd9kg&
device_info=eyJ....eyJ... # NEW!!!

IdPはClientから認証リクエストを受け取ったデバイス情報を検証し、ユーザーインタラクション自体をスキップするかどうかを判定できます。 CIBA の場合、 "ユーザーインタラクションなしで即トークンを返す" ことが表現できないと思うので、互換性を考えるとBackchannel Authentication Endpointからはトークンを返さず、その後の各モードに合わせたやり方ですぐにトークンを返すような実装になるでしょう

ユーザーの許可について

ここまで紹介したデータはトラッキング目的でも使われそうなものでした。 Clientはデバイス情報を収集する前にユーザーに使用目的を説明し、同意を得る必要があるかもしれません。

まとめ

この記事のまとめとしては

  • OIDCのリダイレクトフローとCD/ADが分離するフローでは、デバイスリスクの判断のタイミングが異なる
  • 3D Secure 2.0では、Clientはデバイスまたはブラウザーの情報をパラメーターとして送信し、リスクの判断に使用する
  • このアプローチをOIDCに導入するために、追加のパラメーターを考えてみた

となります。

ユーザーの手元のスマホだけで世の中を動かすの、未来感はあっても毎回操作が求められるUXはなかなかしんどいので省略できるものはできた方が良いでしょう。 あんまりこれ系のプロトコル自体にリスクベース認証がどうこうってのは定義されていないものですが、既存の仕組みで応用できるものがあったらどんどん取り込んでいく、取り込めるように作れるのが標準化ってやつだと思います。

初学者向けの話とか仕様紹介だけじゃなくたまにはこういう話も良いですね。

ではまた!

CIBA is 何

ritou.hatenablog.com

ritou.hatenablog.com

In English

medium.com

OAuth 2.0/OIDCに入門した開発者が仕様沼に潜るための次のステップとは?

f:id:ritou:20200430112204p:plain

お疲れ様です、ritou です。

OAuth 2.0やOIDCの入門書(?)を読み終えた開発者の方に、仕様を理解していくための次のステップは何があるかを聞かれました。

そもそもそんなこと言う人は

  • クライアントを実装したい(しなければならない)
  • 認可サーバーを実装したい(しなければならない)
  • セキュリティエンジニアを名乗っていてこの分野を抑えときたい
  • ただ単純に興味がある : そんな人いる?

とかそんな感じな気はしますが、基本的なフローを乗り越えた先に広がる仕様沼への潜り方に戸惑っておられるようでした。 そこで、いわゆる RFC6749/6750/7636 あたりを完全に理解した開発者が山ほどある仕様にどう立ち向かっていくかを考えます。

仕様にも色々ある

IETF の OAuth関連の仕様、いっぱいあります。密です。密です。みみみみみみみみ...

tools.ietf.org

去年に一回まとめ記事を書きました。

qiita.com

OIDCの方もあります。

openid.net

こっちはまだまとめ記事完成してません(やる気が404 not found)

とりあえず、仕様にはいくつかの種類があります。

  • 既存のフローの一部を拡張
  • 新たな認証認可フロー
  • ベストカレントプラクティス(BCP)

などで分類できますが、まずは自分が求めている仕様はどの辺りかを見極める必要があるでしょう。

コンシューマ向けのサービスであれば Device Flow などの新たな認可フローに手を出すのも良いでしょう。 脅威、脆弱性とその対策を知りたければBCP系を攻めるのもありでしょう。 この2つのやり方はそんなに迷わずに取り組めるかと思いますが、FAPIなどを見据えて既存の認可フローを強化するような拡張仕様を見ていく場合はどこから攻めていくかよく考える必要があるでしょう。

認可フローの分割と対応する仕様の見極め

例えば認可コードフローの拡張仕様を見ていく際、一旦フローを分割して考える方法をお薦めします。

  • 認可(認証)リクエス
  • 認可(認証)レスポンス
  • アクセストークンリクエスト/レスポンス
  • リソースアクセス
  • トークンイントロスペクションリクエスト/レスポンス

そして仕様がこれらのどこに対応するものかを整理します。 どれか一つにだけ対応しているわけではなく、複数のパートに関連している場合がほとんどです。 最近Activeな仕様でいうと...

  • JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens
    • アクセストークンリクエスト/レスポンス
    • リソースアクセス
    • (Implicitだったら認可レスポンス)
  • OAuth 2.0 Demonstration of Proof-of-Possession at the Application Layer(DPoP)
    • アクセストークンリクエスト/レスポンス
    • リソースアクセス
  • OAuth 2.0 Pushed Authorization Requests
    • 認可リクエス
    • (新規エンドポイント)
  • OAuth 2.0 Rich Authorization Requests

という具合に分類できます。仕様がどれに対応するかは仕様のTOCを見れば大体わかりますね。 そうすると各ステップに対応する複数の仕様を比較して考えたりとか、FAPIなどで選択肢として挙げられた仕様への理解も早まるかもしれません。

本当はこれらを全部整理した上で認可フローの各ステップに対応する仕様一覧はこれだ!って出した上でお好きなのから見ていってくださいね〜と言いたいところですがちょっとめんどくさいので、このアプローチに興味ある人がいたら誰か一緒に整理しましょう。

まとめ

  • 仕様の種類を分類して、進むべき道によって方向性を決める
  • 拡張仕様に触れたければ、まずは認可フローの各ステップとの関連を意識せよ

何か相談したいことがあれば声をかけてください。 ではまた。

ID連携におけるCSRF対策のチェック方法

こんばんは、OAuth👮‍♂️です。

f:id:ritou:20200419022241p:plain

緊急事態宣言、外出自粛、みなさまどうお過ごしでしょうか? お家に高い椅子と4KディスプレイとYouTuber並みのマイクを準備し、ようやくOAuth/OIDCを用いたID連携機能の実装に手をつけられるようになった頃かと思います。

本日はID連携時のCSRF対策について、動くものがある状態からのチェックの方法を紹介します。 手元で開発したサービスの登録とかログインにソーシャルログイン機能をつけて「おっ、IdPと繋がった!」ってなったら、Qiitaにその手順を晒すまえにこういうのを試してみましょう。

IdPに遷移する時のURLを確認する

ライブラリとかで作る場合は、登録もログインも既存アカウントへの連携も同じような処理が行われるはずです。 なのでだいたいどこでも良いと思います。

※画像はイメージです

f:id:ritou:20200420005431p:plain

※画像はイメージです

Googleでログイン機能とかをブラウザの開発者向けの何かとかで見張っておきましょう。

※画像はイメージです

f:id:ritou:20200420005535p:plain

※画像はイメージです

こんな感じになると思います。

https://accounts.google.com/signin/oauth?
response_type=code&
access_type=offline&
client_id=hogehoge.apps.googleusercontent.com&
scope=profile+email&
redirect_uri=https://(戻り先)&
state=(stateの値)

OAuth 2.0やOpenID Connectでいわゆる認可/認証リクエストって言われるものです。

この時に、

  • state や nonce とかいうパラメータが存在しない
  • ブラウザを変えても state や nonce とかいうパラメータがいつも一緒

だと、だいたいダメです。 とはいえ、まだWeb Application Frameworkによっては何らかの対策をしているかもしれません。 IdPからサービスに戻る部分を見てみる必要があります。

サービスに戻る際の挙動を確認する

先ほどのURLの「stateを変えたらどうなるかな〜...イッヒッヒ!!!」なんてやらなくていいです。 先ほどのURLを「コピ」して、そのまま別のブラウザを立ち上げて「ぺ」してみましょう。 ID連携を導入している場所にもよりますが、

  • ログイン : ログイン成功したり「このアカウント登録されてねーよ?」とかになる
  • 登録 : ●●さん初めまして!とかになる
  • 既存アカウントとの紐付け : 紐付け完了しちゃう

という挙動をとったら、認可/認証リクエストがセッションと紐づけられていない、つまりCSRF対策が漏れている可能性があります。

どう直せば良いのか?

このへんの記事を読んで

ritou.hatenablog.com

stateやnonceを設定できるかどうか、検証できるかどうかを調べましょう。

stateやnonceを使わずにCSRF対策を実現できている場合も、どうやって実現しているかを確認することをおすすめします。 例えば、「何とかでログイン」っていうリンクを踏んだ時に一時的にセッションに何かの値を持ち、その値がないと処理を受け付けないなどの実装を見たことがありますが、事前に何かのURLを踏ませるなどでCSRFが可能となるような場合はstate/nonceなどを用いる場合に比べると対策が不十分と言えるかもしれません。

外部のサービスでこういうのを見つけたら?

これが自分とこのサービスではない場合は、適切に報告しましょう。

isec-vul-form.ipa.go.jp

以上です。

JSON Web Signatureを簡単かつ安全に使うためのkid/typパラメータの使い方

f:id:ritou:20200325101819p:plain

こんにちはこんにちは、ritou です。

現状、様々な用途で利用されているJWTですが、今後はますます開発者にとって "簡単に" かつ "安全に" 利用できる状況が求められていくと考えられます。 今回はそのために重要になる、各種パラメータの扱いに注目します。

とりあえずライブラリ使えで終わりでは?

JWTを扱うためには

あたりの処理が必要です。

関連仕様がRFC化されてからある程度時間も経っており、各言語で仕様を忠実に実装されたものから自身が使う機能をピンポイントで抽出して実装したものまで様々なライブラリが存在します。 ここで、 仕様に忠実に、全ての暗号化処理をサポートするライブラリ を使うだけで、誰もが安心、安全に利用できるかと言うと、そうでもないことは想像できるでしょう。

JWTの各種仕様とは別で最近RFC化された "JSON Web Token Best Current Practices" では "暗号化処理の細けぇ話" 以外で 気をつけることとして

  • 署名検証処理をちゃんとやれ
  • 複数のJWTを使う場合は、用途を区別できるようにしろ
  • 用途の区別のためにいろんな値を使って検証しろ

と言う内容が書いてあります。

qiita.com

私もJWT(JWS)の鍵管理と署名生成、検証方法について実践していることを以前Qiitaに投稿したことがあります。

qiita.com

この投稿のように、実際のプロダクトでJWT、厳密にはJWSやJWEなどを使う際、

  • 署名生成時の鍵の管理
  • 用途の表現
  • 上記2つの適切な検証

と言うあたりの設計は自由度が結構あって、開発者は自身の設計を基にしてそれにフィットした誰かが作った優秀なJWTのライブラリを使ったり、間を埋める "薄いラッパーライブラリ" のような機能を作る必要があるのが現状だと思います。

本投稿では自身のプロダクトでJWT(JWS)を利用したい、強制的に利用することになった開発者が簡単、安全に実装するために使えるパラメータを紹介しつつ、ライブラリのどのような機能を使っていくべきかを整理します。

署名生成などに利用する鍵の識別子である "kid" パラメータ

JWSを扱いたい開発者にとって、要件としてはもちろん

  • JWSを生成したい : (例 : "JWSを用いたユーザー認証やAPI利用のために、JWSを生成して送る")
  • JWSを検証したい : (例 : "OIDCのID Tokenのように外部サービスからJWSを受け取る")

のいずれか、もしくは両方でしょう。

JWSにて署名生成のためには、鍵が必要です。

  • alg=HSXXX系で利用する共有鍵
  • alg=RSXXX/ESXXX/PSXXX系で利用する鍵ペア

使い分けに関しては個別のユースケースのための仕様で決められているものはそれに従えば良いでしょう。 自前のユースケースの場合は、 JWSの生成/検証それぞれを別のパーティーが行う場合は秘密鍵/公開鍵のペア、単一で生成して検証する場合は共有鍵を利用する ような整理をしています。

これらの鍵を識別するためのパラメータが "kid" であり、いわゆる JWT Header に含まれます。

The "kid" (key ID) Header Parameter is a hint indicating which key was used to secure the JWS. This parameter allows originators to explicitly signal a change of key to recipients.

まずは "kid" の利用の有無と処理の流れを見ていきます。

"kid" を利用しない場合

"kid" を利用しない理由としては

  • 鍵が一つしかない
  • 別の方法 で鍵を識別できる

といったあたりかと思いますが、この場合、識別子を持たない

  • "alg"
  • 鍵のデータ

によって鍵情報が表現されます。

JWSを生成する時には

  1. JWT Header の生成 : 鍵の "alg" の値を指定
  2. JWT Payload の生成 : 送りたいデータを指定
  3. JWT Signature の生成 : 鍵のデータを用いて署名生成

検証する時は

  1. JWT Header の検証 : "alg" が鍵に紐づく値と一致するかどうかを検証
  2. JWT Signature の検証 : 鍵のデータを用いて署名検証

という流れになります。 世の中に出回っているほとんどのライブラリで

  • Payload, Header の値と単一の鍵を用いてJWSを生成
  • 単一の鍵とJWSを指定して署名を検証

といった機能が提供されているため、それを利用するべきでしょう。 よく言われる "alg" の扱いに関する脆弱性を避けるために、1にて "alg" の値による署名検証ロジックを分岐させたりしてはいけません。 気になる方は実装を確認してみても良いでしょう。

"kid" を利用する場合

"kid" を利用して鍵を識別する場合、鍵の表現は

  • "kid"
  • "alg"
  • 鍵のデータ

のようになります。

単一の鍵によるJWSの生成については

  1. JWT Header の生成 : 鍵の "kid", "alg" の値を指定
  2. JWT Payload の生成 : 送りたいデータを指定
  3. JWT Signature の生成 : 署名生成

検証する時は

  1. JWT Header の検証 : "kid", "alg" が鍵と一致するかどうかを検証
  2. JWT Signature の検証 : 署名検証

となります。 こちらもライブラリの機能で提供されるものを利用すれば良いと思いますが、

  • 鍵の表現として "kid" を扱っているか
  • 検証時にどこまでチェックしているか

というあたりはライブラリによって差が出るところかもしれません。

複数の鍵を扱う場合

単一の鍵を利用する場合はそれほど大きな差が見られませんでしたが、ここでは

  • 署名生成には1つの鍵
  • 複数の鍵リストを用いて署名検証

という場合を考えます。

"kid" を利用せずに行うには、上に書いた

  1. JWT Header の検証 : "alg" が鍵と一致するかどうかを検証
  2. JWT Signature の検証 : 署名検証

という処理を 複数の鍵で成功するまで繰り返す、もしくは、

  1. JWT Header / Payload に含まれる値を用いて鍵を識別
  2. JWT Header の検証 : "alg" が鍵と一致するかどうかを検証
  3. JWT Signature の検証 : 署名検証

という処理が必要となりますが、鍵の識別の部分が独自実装となるので簡単、安全にとはならないかもしれません。

一方、"kid" を利用することで、

  1. JWT Header から "kid" を取得
  2. 鍵リストから "kid" が一致するものを探索
  3. JWT Header の検証 : "alg" が鍵と一致するかどうかを検証
  4. JWT Signature の検証 : 署名検証

という流れになります。 1,2 の処理について、ライブラリで

  • JWT Header を(map形式などにデコードして)取得する関数

があったりするので、そこから "kid" を取得して手元の鍵リストと突きあわせても良いですが、 最初から署名検証の処理に複数の鍵リストを指定して1,2が内部で行われるような関数 があればより簡単、安全に利用できるでしょう。

実際にこのようなやり方が必要となるユースケースを紹介します。 OpenID Connect の ID Token の検証の際、ここまで説明した "kid" を用いた署名検証が必要となります。 Google, Yahoo! JAPAN, Microsoft 最近は Apple といった Identity Provider は公開鍵のリストを "jwks_uri" というURIにて提示しています。

$ curl -i "https://auth.login.yahoo.co.jp/yconnect/v2/jwks"
HTTP/1.1 200 OK
Date: Sat, 28 Mar 2020 17:22:35 GMT
P3P: policyref="http://privacy.yahoo.co.jp/w3c/p3p_jp.xml", CP="CAO DSP COR CUR ADM DEV TAI PSA PSD IVAi IVDi CONi TELo OTPi OUR DELi SAMi OTRi UNRi PUBi IND PHY ONL UNI PUR FIN COM NAV INT DEM CNT STA POL HEA PRE GOV"
Strict-Transport-Security: max-age=15552000; includeSubDomains
Expires: Sat, 28 Mar 2020 18:22:35 GMT
Cache-Control: public, max-age=3600
Vary: Accept-Encoding
Content-Length: 985
Content-Type: application/json
Age: 0
Connection: close
Server: ATS

{
  "keys": [
  {
    "kid": "0cc175b9c0f1b6a831c399e269772661",
    "kty": "RSA",
    "alg": "RS256",
    "use": "sig",
    "n": "0bXcnrheJ2snfq1wv6Qz8-TEPDGKHCM0SsrQjxEFpXSEycL2_A-oW1ZGUzCuhz4HH4wkvc4CDJl25johSIUTVyo4mrFrJ0ab0QAhrWE7gMyWFIfraj9cksPAGyVAiXLCN9Ly2xuoJxFjCAZXw1VO8i7RTYK8ZP6dhcosiyzdhYt7C_65B5ikmCS4AymXIa83QQanCtjoGiwy4Cf2pLnn9zXMZEnqQ-wwSoGn32YExmap7GAtjOwHNWU5zpW3dwNMq-zkcln3ICEBwxDpWJhEZHZPBpPWgN-dQZDR2FiGHJgUFE3EM-CIcwxekrRBP-R3xEUeMFf5z1HeQNK8sjZeRw",
    "e": "AQAB"
  },
  {
    "kid": "b0c88084cd7ced792748340968b7d689",
    "kty": "RSA",
    "alg": "RS256",
    "use": "sig",
    "n": "xf9qYN87qbnuzKZFLM756UZXhBZuaB7g8l-jBeQsf2Suf6QUC1A_v30Y4yC0Jht_D5M3RzGzRxvPfBRnKm3NxUDV5Ihmunt3-ZW6ia3bNdd7RRgCj3HdtQRiVroa9nDj_8abXZA1n2v2RpfiJKSoHR8fim2TmfM7EMqXaoe65l1P3drEUkRMAOCMnsCXxCEfpcw_z0tXVTuOI_w3aCI8D3mfPe2fTmCUOiYLV4jhnF5-pMZEBcF4_RsYTdKg_50F4hhgQ0qpkFJ2UI_UMV6tHKw0lSJefcwj5j_pfeW4kfutUjb0xPQ2VrJ5IPM-efF5wtlkIhhQE58U5XuhWnc6Iw",
    "e": "AQAB"
  }
  ]
}

これらを鍵のリストに変換し、署名検証機能に渡してあげられると独自実装をせずに済みます。 Rubyjson-jwtって言うライブラリでは同様の処理がサポートされています。

jwk_set = JSON::JWK::Set.new(
  JSON.parse(
    RestClient.get(idp_jwks_url)
  )
)

id_token = JSON::JWT.decode id_token_string, jwk_set

優秀ですね。

"kid" について、一旦まとめます。

  • "kid" は鍵の識別子
  • 単一の鍵でのJWS生成/検証は "kid" 使わなくてもそれほど違いはない
  • 複数の鍵を考慮する際には "kid" を利用することで簡単、安全を実現できそう

「とりあえず "kid" はなしで良いかな...」とは言わずに、最初から利用すべきだと思います。

次は、JWTの用途に注目します。

"typ" パラメータでJWTの用途を表現

ざっくりといってしまうと、JWS の Payload に含まれる値というものは、Base64 URL エンコードできれば何でも良かったりします。 とはいえ、異なるサービスなどでやりとりされるデータの場合、RFC 7519 で定義されている "発行者"、"受信者"、"有効期限"などの標準的なクレームを利用することで送信側、受信側双方で取り扱いがしやすくなるでしょう。

ふむふむ、これは便利だとあるサービス内でJWSを色々な場所で使っていくと、用途が異なるのにPayload に含まれるクレームの key が一緒のものが使われるようになる 場合も出てきます。 さらにJWSの署名生成/検証に使う鍵がサービス内で共通だったりすると、用途Aで処理した、もしくはすべきJWSを用途Bで処理することで意図しない挙動を発生させるなんていう脆弱性が生まれる可能性が出てきます。 それを防ぐためには、"用途" をどこかに含み、検証する必要があります。

このために利用できるのが、JWT Header の "typ" パラメータです。 (仕様では "cty" というパラメータもあり、コンテンツの種類を示すっぽいネーミングだしこっちじゃね?と思いましたが、JWT BCP では "typ" パラメータの利用について言及されていたため、"typ" の使い方について紹介します。)

JWSを扱うだいたいのアプリケーションは、各種検証をした後に中身のデータを処理しますが、"typ" パラメータを使うことで、用途の検証を署名検証などと同じタイミングで行う方が簡単かつ安全でしょう。 単一の鍵を用いたJWS生成時の流れは

  1. JWT Header の生成 : 鍵の "kid", "alg" と用途を示す "typ" の値を指定
  2. JWT Payload の生成 : 送りたいデータを指定
  3. JWT Signature の生成 : 署名生成

となり、検証する時は

  1. JWT Header の検証 : "kid", "alg" が鍵と一致するか、"typ" が意図したものであるかを検証
  2. JWT Signature の検証 : 署名検証

となります。

"kid" が鍵に紐づくものであるのに対して、"typ" は独立しているため、複数の鍵の場合は署名検証よりも先にするか後にするかを考える必要があります。

  1. JWT Header から "kid", "typ" を取得
  2. "typ" が意図した値と一致するかを検証
  3. 鍵リストから "kid" が一致するものを探索
  4. JWT Header の検証 : "alg" が鍵と一致するかどうかを検証
  5. JWT Signature の検証 : 署名検証

もしくは

  1. JWT Header から "kid", "typ" を取得
  2. 鍵リストから "kid" が一致するものを探索
  3. JWT Header の検証 : "alg" が鍵と一致するかどうかを検証
  4. JWT Signature の検証 : 署名検証
  5. "typ" が意図した値と一致するかを検証

となります。 署名検証の負荷まで考えると前者でやりたい気もしますが、ラッパーライブラリを作る場合などは後者の方がシンプルな場合もあるでしょう。

これをライブラリで実現したい場合、"kid" と同様に

  • JWT Header を(map形式などにデコードして)取得する関数

を利用して "typ" を取得してその値を検証する処理を行うか、最初から"JWS検証"と言った機能を用意して、そこに JWT Header の想定する key/value を指定して実際の値との比較が内部で行われる ようになっていれば、より簡単、安全に利用できるでしょう。

既に "typ" パラメータを利用する JWS を利用している RFC もあります。 サービス間でセキュリティイベント情報をやりとりするための仕様である RFC 8417 : Security Event Token (SET) では "typ" パラメータに "secevent+jwt" を利用すべきとあります。

Payloadのクレームにて用途を表現

"typ" の利用を意識する前は、「別にこんなの JWT Payload に適当に "usage" とか用意したら一緒じゃん」と思ったことが私にもありました。 Payloadに含む場合、

  1. JWT Header の検証 : "kid", "alg" が鍵と一致するかを検証
  2. JWT Payload の検証 : "usage" が意図したものであるかを検証
  3. JWT Signature の検証 : 署名検証

とするか、もしくは

  1. JWT Header の検証 : "kid", "alg" が鍵と一致するかを検証
  2. JWT Signature の検証 : 署名検証
  3. JWT Payload の検証 : "usage" が意図したものであるかを検証

のような処理が考えられますが、"kid" のところでも一旦 JWT Header をデコードしているので同じく JWT Header の中にある値を利用するほうが効率的な気がします。

"kid" にて用途を表現

ここまで書いておきながら、実際に開発したプロダクトでは、"typ" を使わずに鍵リストをを用途ごとに用意することで対応しました。 この場合は "kid" の値に "用途" に関する文字列を含んだりすると人間にも優しい感じになります。

"typ" パラメータを使うべきかどうかは鍵リストと用途の数の関係にもよるでしょう。

  • 鍵リスト : 用途 が 1:1 ならば "typ" は不要かもしれません
  • 鍵リスト : 用途 が 1:n ならば "typ" を使って用途を別で検証すべきでしょう

例えば

  • 自サービスのいくつかの機能でJWSを生成、検証する。JWSをハンドリングするための汎用的なモジュールがあって利用するときは "typ" の重複に気をつけながら使う
  • 様々な種類のトークン発行を目的としたサービスがあり、社内外の別のサービスが検証する

と言った使い方まで踏み込んでいく場合は、用途の表現に "typ" パラメータを使っていくのが良いかと思います。

まとめ

JWS をサービスで利用する際は、"kid", "typ" を使って鍵管理や用途の検証を行うことをお勧めします。 ここで書いたような細かい実装を意識しなくても良いようなライブラリがあれば「簡単、安全」が実現できると思います。 ちょうど良いライブラリがなかったらラッパーライブラリでも作れば良いと思います。

おまけ : Elixir で上記の処理を実現するためのラッパーライブラリ

Elixir, Erlang では JOSE という JWT のライブラリがあり、それを使って上記のような処理を実現するラッパーライブラリ KittenBlue を実装しています。

鍵リストを用いた署名検証

JWSの署名検証の関数では、引数に鍵のリストを受け付けます。

@spec verify(token :: String.t(), keys :: List.t(), required_header :: map)

{:ok, payload} = KittenBlue.JWS.verify(token, kb_jwk_list)

鍵のリストを生成する関数も用意しています。

@spec public_jwk_sets_to_list(public_json_web_key_sets :: map) :: List.t()

kb_jwk_list = KittenBlue.JWK.public_jwk_sets_to_list(public_jwk_sets)

GoogleのIDTokenの例を紹介しましたが、public_jwk_sets というのに jwks_uriJSONレスポンスを指定することで鍵のリストを生成できます。 って言うのを個人的によく使うので、Googleの場合は1つの関数呼び出しでHTTP GETで取得したものを鍵のリストに変えるものを用意しました。

iex(x)> kb_jwk_list = KittenBlue.JWK.Google.fetch!()
[
  %KittenBlue.JWK{
    alg: "RS256",
    key: %JOSE.JWK{
      fields: %{
        "alg" => "RS256",
        "kid" => "cb404383844b46312769bb929ecec57d0ad8e3bb",
        "use" => "sig"
      },
      keys: :undefined,
      kty: {:jose_jwk_kty_rsa, 
       {:RSAPublicKey,
        23559603576875225300516496747863315901159811550023890989775190602953070457251744574277164711804232777930691164503766335809595820519587969666799742618661883170645295829810454994446891146636842862247943583070585283519455642403178376555771276441887509803782686400621436423897652203980773024251492902834360213913266119209668280813169144553844586444664567379373320411287000801783329657597460647965207784138972860305800997638056311176902160490179340442844385059621186040784773075957366134393281123221196446107212037616578676463217957073092746307384684405844850216745668358506167614836872166650356637194235779935491814491191,
        65537}}
    },
    kid: "cb404383844b46312769bb929ecec57d0ad8e3bb"
  },
  %KittenBlue.JWK{
    alg: "RS256",
    key: %JOSE.JWK{
      fields: %{
        "alg" => "RS256",
        "kid" => "a541d6ef022d77a2318f7dd657f27793203bed4a",
        "use" => "sig"
      },
      keys: :undefined,
      kty: {:jose_jwk_kty_rsa,
       {:RSAPublicKey,
        18762754202955820134622590402370752480881774188179408616754635358623594945478938027256785796402587678256230913279330972176390848402816954890173143705716195619041968607912861390415901526508453885537509057572453582237323091784128038040790496847188179617000650969981899214218795261781625441530308527714819530349949418550772542624424910914978261040800935020496046984026021447949276764747792890846218651552541231126454438533384071335837361262551487823641818719455020363417580745785975945076349317744486057798572886663924803577041289507190965386424906046073948681270612506806128266494262830084437561463759567010172055162873,
        65537}}
    },
    kid: "a541d6ef022d77a2318f7dd657f27793203bed4a"
  }
]

JWT Header パラメータを指定した JWS の生成

KittenBlue ではJWSの生成時に鍵情報(KittenBlue.JWK) から alg, kid クレームの値を利用します。 "typ" パラメータのように、JWT Header に値を入れたいときのために、JWS生成時に引数に指定できるようにしています。

@spec KittenBlue.JWS.sign(payload :: map, key :: KittenBlue.JWK.t(), header :: map) ::
          {:ok, String.t()} | {:error, :invalid_key}

iex(x)> rs256_jwk_1 = [
...(x)>                  kid: "rs256_first",
...(x)>                  alg: "RS256",
...(x)>                  key:
...(x)>                    ~S"""
...(x)>                    -----BEGIN RSA PRIVATE KEY-----
...(x)>                    MIIEowIBAAKCAQEAvTpKoAgqi3TtyT20ncxKkcNOOJEmOgy96Spry+AC0F+2UDFG
...(x)>                    JJ7shvhhEwxZy5+24H+Td5DGV1DKN0Gn2wb8dfWMH1x0HzsDEtJldFTf5GCK96QC
...(x)>                    U79XtwedX7p8Yvt5cDGnVCVlODhM9S7/5Ztvnm3PsE/8ZFnsLUI4zdx4qg5295x0
...(x)>                    oYU1zmBDAOl3y9i9vGdhmtqZ1uwVXJXTziWooV9z7Qyi3Y4+6QOgj/6p6GSFDZv9
...(x)>                    CYHMYZPWk6+dFmnSrOaHfA5C5W++vdlAinhn8zWxO3ROdaKklmV9doF45cq843SK
...(x)>                    +E+N/aYYEmTkpCrOApyI76nNrFzdrsRb+2KVUwIDAQABAoIBAB2opUmv/fsduKdy
...(x)>                    JH0XKBjwo7H6DiPLG3kQTRUHZ2mBlvG6x2O2BRyikZSKuwhPYDqPxG1ZI71LzGYc
...(x)>                    xFJwJeHXOr8vnoPGnBS3JW+2XeFNwHpQGo1F0Fm/t8rpT9Wz1LThE3j844CMUoOb
...(x)>                    ekBivHv4ejUIVGbmMT5mwsCBbeg5VwFWN1Q74KHJgpTW/uY9ItbZp1chXpzJffxz
...(x)>                    QuU6eefkHbaHDuFYlJ9OSs6raZihyZSso/Td8M2g5O12ZbtK7Qc5AYoURfedVbRp
...(x)>                    K4f+LUyHH8jmtXqU1xN/4yCOUlsiS8eQ74zwPEcTXG1aRwa/QIGSJ4bvvkbka3F7
...(x)>                    smgpqwECgYEA7DjXusxmai1Eu3RGTfKWfLA/Br3j9FMGruxqa9R8xn6PQWDLuczl
...(x)>                    4ttIN9ST/lWR/XTMtdEFv0zEtze3ytVvKgbaRtqwUZCChe8wluMQRbN+/yBIW46X
...(x)>                    n6pdSzIfwS8Q3YgdOVZd+N1zgE7u3bUseS0uNIAHHwFNSEJA4bxhgDcCgYEAzRIt
...(x)>                    YdihERIZ01qN77MTxbuyXm6wuLOLaXrnomFmtjbM3iVBkrmLGhANTOUhfgPI54ka
...(x)>                    bXaklSqyv0zukgMn6MthXg+tSydi683jrgLg0wdhDje4Wb+1Pu6mViTYEzEHDvYj
...(x)>                    s8duj8J/3SEASRnnBdwku3yc+EW1zkxvcWCY7cUCgYEArmyqnvwfA3e5sNECuLvP
...(x)>                    8vIRF+FPWTGVVcSsMEMOf2MkVJos1F0/wms4wEDvpnV4/zYnknltTPxapQ83X0aK
...(x)>                    dvXoZzlDyHZ0aoFb146CjXUk6S3lP/Xib7tUeBni6LrgMTQ4oAXuDb03dB7UslD9
...(x)>                    Ldz2qT2ABJzpe9mwHv8C37ECgYBfvNC7EWuAkLbF2UzSTwQ4F/yZ4YtXb1ryj5J8
...(x)>                    WISfJM5YF4SZf03ViRDsiTwtnI66qWNRH0aO7TQt4zitqhODtw9p3l/E6kpgU+qr
...(x)>                    XmSfoJ5LCPBj1gBDtR6qsOC/dPAaqAba84xGSUNwdOuxNQqJzdDIRtDxh3ntKfoN
...(x)>                    ME+1EQKBgFQQA3KJiwu0Vy0xmvCa9L5x+Ye8XZc8rH8k/aUZqaw02kzGj+tJha5u
...(x)>                    y5S2rrlPsZse3QHXRO2bklSM4w8TX3OfZ+/UwnikTZXCVI0LzZfKvbQeJHe9xfcm
...(x)>                    HHZOuo4XBKSFKckk5uKh6uOIVkdu47wDuJ6AQLjdNY73+82T/ZBl
...(x)>                    -----END RSA PRIVATE KEY-----
...(x)>                    """
...(x)>                    |> JOSE.JWK.from_pem()
...(x)>                ] |> KittenBlue.JWK.new()
%KittenBlue.JWK{
  alg: "RS256",
  key: %JOSE.JWK{
    fields: %{},
    keys: :undefined,
    kty: {:jose_jwk_kty_rsa,
     {:RSAPrivateKey, :"two-prime",
      23887784250727630316275809631128181418019737776255801605864618160204240776719803666317908074747681159398695862838220403653302425517683356761243070504760826171598857148793348816474961674958362252696860381597038089154426567710056288030876197461232648892712282755310983552228486351250470069084197476500525614553744825922598437607204933566247107733336312172470812156739795950920867166810567432860798141104064634233139621588930688857402065189366212046691810735625674464012122874442883055968679298939504932914622476516593055298437166198578018245264529279821684455040347159389391921905462963843464444466480442655050794440019,
      65537,
      3744073116307951517597465806047708615376027990870799610837257697813722954338248977835689026714805085209017866290403893774916802949748133735927625924667030935725826031591395380362708185073657583650489797210198441365858518142693412738653894751389013372994500335116871737316187982361793010812714596008566139931533083564795373872347186983542263400276129961867924891969921891909900625784243989908469276253045564221677183175334438753932794161715983643564958167118861179237681564968441043741184083339090247362671044715659085409294554087654935608902188295421556518561902693797902685451096957145838130713682410664715247790849,
      165880758906150399291135048661183463406407433113424473810503004412871873812435170415220873632378991804875248755073671792436986556673474817159545219329130498920059671266176701275614478922948106193874806041323257371549793099599031745489910172499363570300750341362384960831710405729252739377305236834547220447287,
      144005756956070553706478293636419162463322836033965404401959318188373280534931486877883443698725445543912975466071472612424820554774082629812964172987664963563642572010748344211563528175244760773768284033471756603976820992024010350960102526613349938770806237474714486734914816635746598724046297357356845559237,
      122485034178958910577179414297450145126613493190485330983076146139550112417891614915747875502663902397447595064704291093269613400835295990844632989819114135583556275263024290524887252453412648655476900284598243293468385609286301543063425745267394020080167089113150327670905442025878489463151426230944490774449,
      67229200906784482982184260373527636216607801566980568428251938588758546946713517135598544996051142589095646693622271023234963603672243650788981061640456493663017961353752175709858518211846570649163288192747636679360892550345096978774971342970567080071281682740686397548582537036899811321252430137871584703761,
      59030731919528272000968194889255430549869261117598269061736354937748840662335109895164381473069940718361084490370316136012764124770337022214514681586404858422354002075066391172100445960650914065118822097887628485137753546773317882566753980768787835222561197839761197921305443848496610762768920267943487311973,
      :asn1_NOVALUE}}
  },
  kid: "rs256_first"
}

iex(x)> payload = %{"foo" => "var"}
%{"foo" => "var"}

iex(x)> {:ok, jws} = KittenBlue.JWS.sign(payload, rs256_jwk_1, %{"typ" => "secevent+jwt"})
{:ok,
 "eyJhbGciOiJSUzI1NiIsImtpZCI6InJzMjU2X2ZpcnN0IiwidHlwIjoic2VjZXZlbnQrand0In0.eyJmb28iOiJ2YXIifQ.dWayLDJ60IGsWXYmSBbChul1aq80AiDUVbfWaC_UVObPpd2K2PtycFOMeJ5WEPNOnihCu0KgGlb2bL5Kj7bzhg1oklC-r3uNJ6C8OtkFwrzOLQ1iWGcDjTku4WHLK6Z09UHI3elwU7gFnNAW_S0HB2KMbGj-H8bu1CeJv2h71hH4KD_lky1Bp1l19VPnT1OOe7TIOClZv7kS9OfjzLDBDWcHnqQx_XkEbmf1_yh46407bFBFo6hPOJY42bgkxrl8CRRO2Nk-ihT_SrjNWRGGiTzxD0Cj9U2CrtOXxcSHFfos68XBdTq9TVT9nznhj1T4ZrWihqohMv2e5llsk7CDXQ"}

 # decoded JWT header
 {
  "alg": "RS256",
  "kid": "rs256_first",
  "typ": "secevent+jwt"
 }

JWT Header パラメータを指定した JWS の検証

上記のように指定できるようにした typ クレームの値を署名検証時に検証できます。

@spec verify(token :: String.t(), keys :: List.t(), required_header :: map) ::
          {:error, :invalid_jwt_format}
          | {:error, :invalid_jwt_kid}
          | {:error, :invalid_jwt_signature}
          | {:error, :invalid_jwt_header}
          | {:ok, payload :: map}

iex(x)> KittenBlue.JWS.verify(jws, [rs256_jwk_1], %{"typ" => "secevent+jwt"})    
{:ok, %{"foo" => "var"}}

ではまた。 ご安全に!

OAuth 2.0 / OpenID Connect の Hybrid Flow への向き合い方

ritouです。

f:id:ritou:20200312114947p:plain

OAuth 2.0 / OIDC を触って「そろそろ完全に理解したって言っちゃおうかな」なんて思った時に出会ってしまうのが Hybrid Flow です。 某書籍のレビュー時に Hybrod Flow について著者といくつかやりとりをしたのですが、なんだかんだで結構ややこしいので私の考える向き合い方を書き残しておきます。

Hybrid Flow とは

Authorization Code Flow(Grant) や Implicit Grant(Flow) に比べて、まず定義からよくわからないと言う声を多く聞きます。 仕様を紹介している記事ではこんな感じで書かれています。

Hybrid Flow Authorization Code といくつかのトークンが Authorization Endpoint から返され, その他のトークンが Token Endpoint から返される OAuth 2.0 のフロー.

f:id:ritou:20200311135326p:plain

Final: OpenID Connect Core 1.0 incorporating errata set 1

OpenID Connect の「ハイブリッドフロー」を用いることにより、単一のクライアントに対して 2 つのアクセス・トークンを発行することが可能です。想定されるクライアントとしては、モバイル端末側のネイティブアプリケーションとWebサーバー側のバックエンドアプリケーションからなる構成が挙げられます。このようなクライアントに関して、ネイティブアプリケーションに発行するほうのアクセス・トークンについてはスコープを制限し(リクエストされているスコープのサブセットとし)、パブリッククライアント特有のセキュリティリスクを最小化することが、しばしば求められます。

ハイブリッドフロー: スコープを制限したアクセストークンの発行 — Authlete ナレッジベース

このフローはAuthorizationエンドポイントとTokenエンドポイント双方からAccess Tokenが発行されることになることから、Hybrid(ハイブリッド)フローと呼ばれる。なおHybridフローはRFC 6749では定義されておらず、OpenID Connectの策定段階においてOAuth 2.0の拡張仕様として出てきた「OAuth 2.0 Multiple Response Type Encoding Practices」(英語)というドキュメントに定義されている。

www.buildinsider.net

ネイティブアプリの場合、OAuth 2.0ではImplicitフロー(response_type=token)を使うケースとHybridフロー(response_type=code token)を使う場合があった。

OpenID Connectでもネイティブアプリ側で受け取ったID TokenをBackend Serverに送ることは不可能ではないが、そうするとID Tokenがブラウザーの前のユーザー(正規のユーザーとは限らない)によって改ざんされる恐れがあるため、ID Tokenの署名検証が必須になり、Implicitフロー(response_type=token id_token)を使うとHybridフロー(response_type=code token)を利用する場合と比べて複雑になる。

www.buildinsider.net

全パターンの解説はこちらに詳しく書かれています。

qiita.com

と言うことで、仕様としては

  • OAuth 2.0 の AuthZ Request で response_type=code token とすると AuthZ Response に "response_type=code" と "response_type=token" の場合のレスポンスが合わさったものが返される。
  • OIDC ではそれに ID Token が絡む。仕様では code id_token,code token, code id_token token に言及。

と言う感じで、

  • AuthZ Code 以外を含むので flagment に指定される
  • AuthZ Code を Token Endpoint に送って Token を取得する
  • AuthZ Response に含まれる Token と Token Response に含まれる Token は内容(Access Token の scope や expiration, ID Token の payload の中身)が必ずしも一致しない

と言うあたりでしょう。

そして、これを適用するユースケースとして

  • ネイティブアプリや SPA が AuthZ Response に含まれた Token を利用してリソースアクセス
  • ネイティブアプリやSPAのバックエンドサーバーが AuthZ Code を使って Token Request を送り、Token Response に含まれた Token を利用してリソースアクセス

と言うのが例として上げられます。

(図描くのがめんどくさい!!!)

効果的な使い方

例えば Google は response_type の組み合わせに対応していますが、それだけで「じゃあ使おうか」となるかと言うと別でしょう。 次のように、提供するリソースアクセスに幅がある IdP の場合に、効果が発揮できると思います。

  • SNSへの投稿や広告/ターゲティング系など : IdP が単体で Public Client からの利用を許可していたり、ネイティブアプリや SPA からの利用を想定している機能
  • 決済のための機能 : IdP が Confidential Client のみに利用を許可している機能

この辺りが想像しにくい部分かなと思っています。

もちろん、Authorization Code Grant を利用してバックエンドサーバーに Access Token を保存しておき、全てのリソースアクセスをバックエンドサーバーから行う設計の方が安全と言えるでしょう。 しかし、負荷などの面で Public Client からの利用が許可されているものはネイティブアプリ/SPAからしちゃうような設計の場合、Hybrid Flow の選択肢が出てくるかもしれません。

AuthZ Response / Token Response に含まれる各種トークンの違い

最初に、AuthZ Response に含まれる Token と Token Response に含まれる Token は内容(Access Token の scope や expiration, ID Token の payload の中身)が必ずしも一致しないと紹介しました。この辺りに言及された記事がありました。

Access Tokenが違った値が返されることがあります。これは例えばAuthorization Codeと引き換えに得たAccess Tokenはセキュリティ的により強固なフローを経ているのでより長い有効期限のTokenにしよう、などというようにサーバー側で設定されている場合にありえます。

ID Tokenについても、認証リクエストのID Tokenで得られる属性情報はいくつかのClaimが抜けていて、完全な形の属性情報を得られるトークンをToken Requestで返す、というようなサーバー側の実装があり得る(プライバシー上の理由などから)ので、Token Requestでより完全なトークンを得られるというメリットがあります。

qiita.com

有効期限の話で言うと、逆にセンシティブなscopeが付与されたAccess Tokenの有効期限が短くなるような場合もありえるでしょう。 このようなIdPの場合、複数の scope を Access Token に紐づけると有効期限が短い方に引っ張られてしまう可能性があり、全てをバックエンドサーバーで行う場合にRefresh Token を用いた Access Token の更新作業が多く発生する事になるでしょう。 Hybrid Flow を利用することで、ネイティブアプリや SPA では有効期限が長い scope が付与された Access Token を扱い、バックエンドサーバーでは短い有効期限である Access Token を扱ったり、極端な場合は使うたびに Refresh Token から取得し直すと言う設計もありえるかもしれません。

このような理想形を実現するためには IdP 側も scope 管理をよく考慮して実装されている必要がありますし、Client 側もそれを理解した上で設計を行う必要があります。

各種攻撃、対策の考え方

書籍のレビューをした際、ネイティブアプリ、SPAとバックエンドサーバーの組み合わせに対して起こりうる攻撃とその対策を考えるときに、

  • ネイティブアプリ/SPA : Public Client としての攻撃を想定し、対策を行う
  • バックエンドサーバー : Confidential Client として攻撃を想定し、対策を行う

として組み合わせるべきだという話をして、実際にその考えを採用していただきました。 たまに「この攻撃、ネイティブ側で対応したら問題ないのでは?」と言う考えを見かけますが、避けるべきでしょう。

また、設計としてネイティブアプリ/SPA とバックエンドサーバー間でアクセストークンのやりとりも避けるべきでしょう。

  • バックエンドサーバーにAuthorization Code を送る : バックエンドサーバーが Client 認証を行い Token Request を送れる、PKCE や ID Token を利用した検証も可能 なので使うべき
  • バックエンドサーバーにID Token を送って認証 : nonceなど Payload の値を検証が可能。Hybrid Flow の話とは変わるが、バックエンドサーバーで認証だけを行う場合は選択肢としてありそう
  • バックエンドサーバーに Access Token を送ってリソースアクセス : Access Token の厳密な検証など、置き換えなどへの対策が困難 使ってはいけない。
  • バックエンドサーバーからネイティブアプリ/SPAに Access Token を渡す : サーバー間通信でしかやりとりされず、ClientSecret と同様に安全に管理されることを前提としている Confidential Client 向けに発行された Access Token が利用者の端末を経由してリソースサーバーに送られるため、漏洩時のリスクが大きい場合がある。これも使うべきではない。

まとめ

  • Hybrid Flow とはどんなものか
  • 効果的な使い方、それに必要な IdP / Client の設計
  • 各種攻撃対策の考え方、やりとりされるトークンの扱いについて

と言うあたりを書きました。 例の書籍を読む際の参考になればと思います。

ではまた。

人はなぜ「フィッシング対策のための2段階認証」「2段階認証を破る新手口!」と雑に言ってしまうのか

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

最近、こんな記事を見かけることが多くありませんか?

  • フィッシング被害が増加!
  • 2段階認証を導入しているサービス、多いよな!!
  • それでも突破される!!!新手口とは!?

と言う流れの記事です。

それらを見かけるたびに、シャーシャーと別方向に威嚇をしてきたのがこちらの猫となります。

本投稿で言いたいことは、これらの記事の説明の仕方、もうちょっとなんとかならないかと言うお話です。

ここで言う2段階認証とは

フィッシング詐欺が増えていることに疑いの余地はございません。 重要なのは「対策してたのにやられるんだ!」と言うあたりを強調させるべく使われている「2段階認証」です。 わかりやすいのは、ワンタイムパスワード(リカバリーコード含む)でしょう。

「フィッシング攻撃への対策」と言い切って「それでも破られる」と言う流れにするのはいかがなものでしょうか。 TwitterのbioにIdentity, いやそれよりもSecurityなんて文字を含めてしまう皆様は穏やかにお過ごしなのですか?(ヤバい方面を煽ってる気がして震えてる)

ワンタイムパスワードはフィッシング攻撃への対策と言うよりも、パスワードリスト攻撃対策と言うべきでは?

そもそも

  • 人類はフィッシング攻撃に脆弱であり、偽サービスにクレデンシャル(ここでは"ID/パスワード")を入力してしまう
  • 人類はサービス毎に異なるパスワードを生成、管理できない
  • サービス側からクレデンシャルが漏洩することもあった

と言う状態からの「不正ログイン」、規模も大きくなる「パスワードリスト攻撃」に対し、クレデンシャルをワンタイムにすることで防ぐ対策が導入されたわけですが、当然ながらワンタイムなクレデンシャルを未使用の状態で悪意のある第3者に入手、利用されてしまうフィッシング攻撃に対しては無力です。

f:id:ritou:20200308021927j:plain

また、ID/パスワードの認証の後に「手元の端末に通知、確認を求める」認証方式もありますが、こちらも正規のサービスに同期的にクレデンシャルを入力していくフィッシングサイトを見分けることができなければ、そのユーザーを救うことはできないでしょう。

話を戻して、これらの認証方式を「フィッシング対策」としてしまった結果、破られるパターンを「新たな手口」と書かざるを得なくなるのだと思っています。 やはり「フィッシング対策」と言うべきではないのではありませんか?

そして、ここからはその新たな手口への対策についてです。 相変わらずURLや不審ななんとかは…という啓蒙で何とかさせようと思ってる記事も多いですが、ユーザーに任せてもげんかいがあるでしょう。システムでフィッシング対策が行われる世界を目指すべきです。

パスワード管理ソフトやブラウザの機能で救われる人、救われない人

記事を書く人がわかっていないのか、立場もあるのかもしれませんが、「新たな手口やばい!みんなやられちゃう」だけで終わる記事もそれなりにあります。

上に書いた通り、フィッシングなんてのは人類のバグをついたものなのでなかなか厄介ですが、パスワード管理ソフトやブラウザの機能を「普通に」使うことで、サービス毎にパスワードを生成、管理することで、偽サービスにクレデンシャルを入力することを防げる/防いでいる人もそれなりにいるでしょう。

これらが提供する機能として

  • サービス毎に複雑なパスワードを生成できる
  • スターパスワード一つ覚えておけば利用できる

と言う2点まではよく書かれていますが、フィッシング対策としては

  • 生成したものをサービスのドメイン単位などで記憶し、自動入力する

と言うあたりも、もう少し強調しても良いのではと思います。 もちろん、普段使っていない端末での認証を試みたときだったり、自分で対象のサービスに対するパスワードを調べてから入力しちゃうようなユーザーの介入があるとリスクは残るため、完全にこれだけで防ぐのは難しいとも言えるでしょう。

SMSで受信したワンタイムパスワードの取得と入力の(半)自動化

AndroidなどでSMSで受信したワンタイムパスワードの値を取得、入力する処理を(半)自動化するような機能があり、Webブラウザの世界でも検討されています。 特徴としては、ワンタイムパスワードを自動で判別し、かつワンタイムパスワードを発行したアプリやサービスを識別可能な情報も含まれています。

developers.google.com

web.dev

これらの値を使ってワンタイムパスワードを発行したサービスと入力するサービスが一致することを確認できれば、フィッシング対策としても有効と言え流でしょう。

こちらも、PCを使っていてスマートフォンで受け取ったワンタイムパスワードを入力するケースや、これらの機能に非対応の環境ではユーザーが介入してしまうため、リスクは残る訳ですが、この辺りを踏まえて今後どのように検討が進められていくかは注目でしょう。

FIDOを忘れてはいないか?

長々と余計なことまで書いてきましたが、フィシング耐性を備えた2段階認証、ありますよね。 まぁ、多くを語る必要はないでしょう(疲れた)。

WebAuthn では Authenticator(セキュリティキーなど)が管理する秘密鍵は origin 単位で生成され、Client(Webブラウザ)が origin の検証をちゃんとやることで、偽サービスに正当なクレデンシャルが渡ることを困難にしています。

f:id:ritou:20200308035641j:plain

なぜアピールしない?ねぇ、どうして??? そろそろ終わりましょう。

この内容どこかで...

これの前半部分です。

speakerdeck.com

と言うことで、記事を書かれる方には「2段階認証」にも色々あるよねってあたりを意識して欲しいですし、 FIDOを普及させたい団体の皆様におかれましては、この辺りもうちょっと明確なアッピールをした方が世のため人のためになるのではないかと思います。はよ!!!

私からは以上です。

f:id:ritou:20200308022819p:plain

そのIDTokenの正体はセッショントークン?それともアサーション?

f:id:ritou:20200108042739p:plain

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

今年も Firebase Authentication や Auth0 とか沢山使われると思いますが、去年からよく聞かれる件をざっくりと書いておきます。

ID Tokenってセッショントークンとして使っていいんですよね?

例えばこちらの記事を見てみましょう。

Google の ID Token をセッショントークンとして使おうとして断念

techblog.kayac.com

認証は GoogleOpenID Connect の Auth Code Flow による認証を採用しています。ユーザと Google 間の認証後の callback で code を受け取り、code exchagne で ID token を取得します。

Google ID token は以下のような Google アカウントのユーザ情報を含む JWT です。

これはいわゆる OpenID Connect の ID Tokenのことです。

Google 発行の ID token は exp(Expiration Time)クレームが1時間なので、この ID token をそのままセッションキーとして使ってしまうと1時間でセッションが切れて再認証が必要になるサービスになってしまいます。ひとつ工夫が必要ですね。

なぜ工夫が必要なのか。なぜ使いづらいのか。こちらのIDTokenはそもそもセッショントークンとしての利用を想定されているものなのか。

この記事では結局諦めたようです。

Google先生に聞いてみます。

Google のドキュメント

developers.google.com

Authenticating the user involves obtaining an ID token and validating it. ID tokens are a standardized feature of OpenID Connect designed for use in sharing identity assertions on the Internet.

GoogleのID TokenはOIDCのID Tokenです。さっきも言った。

After obtaining user information from the ID token, you should query your app's user database. If the user already exists in your database, you should start an application session for that user if all login requirements are met by the Google API response.

検証しておたくのDBと突き合わせ、ユーザーがいたらアプリケーションセッションを始めろと書いてあります。

とりあえず、明確にセッショントークンとして使えとは書いてありません。

OpenID ConnectにおけるID Tokenは

  • アサーションとして認証イベントの結果をIdPがRPにお伝えするためのもの
  • セッション管理などいくつかの拡張などで処理の対象となるユーザーを表現するために指定する

と言う感じです。

ではなぜお母さんはセッショントークンだと思ったのか。Firebaseさんに聞いてみます。

FirebaseにおけるID Token

firebase.google.com

Firebase クライアント アプリがカスタム バックエンド サーバーと通信する場合、そのサーバーに現在ログインしているユーザーを特定する必要が生じる場合があります。これを安全に行うために、正常なログイン後、ユーザーの ID トークンを HTTPS を使ってサーバーに送信します。次に、サーバー上で ID トークンの完全性と信頼度を確認し、ID トークンの uid を取得します。サーバーで現在ログインしているユーザーを安全に特定するために、この方法で送信された uid を使用できます。

これは...セッショントークンっぽいですね。

ユーザーのログインまたは端末でのログインが成功すると、Firebase で独自の ID トークンが作成され、この ID トークンでユーザーまたは端末を特定し、Firebase Realtime Database や Cloud Storage などのリソースへのアクセスを許可します。この ID トークンは、カスタム バックエンド サーバーのユーザーまたは端末を識別するために再利用できます。クライアントから ID トークンを取得するには、ユーザーがログインしたことを確認してから、ログインしたユーザーから ID トークンを取得します。

今時のいわゆるIDaaSと呼ばれるサービスは、ユーザーの認証機能だけではなく、セッション管理まで提供しています。 なので、それを使うサービス(のバックエンド)は、OIDCにおけるClientと言うよりも、むしろFirebaseアカウントに対するリソースサーバー、API提供者として振る舞います。

FirebaseのIDTokenはバックエンドサーバーがいるSPAのセッショントークン的に扱われることを想定していると言えるでしょう。

他のところはどうでしょうか?Auth0を見てみましょう。

Auth0におけるID Token

auth0.com

キーワードっぽいのから拾っていきます。

However, beyond what is required for JWT, ID Tokens also contain claims asserted about the authenticated user, which are pre-defined by the OpenID Connect (OIDC) protocol, and are thus known as standard OIDC claims. Some standard OIDC claims include:

OIDCで定められたclaimを持ってるって書くと、OIDCのID Token、つまりアサーションとしての利用を想定しているのか?と思えてくるような来ないような。

By default, an ID Token is valid for 36000 seconds (10 hours).

しかし有効期限は10時間。これは明らかにアサーションではなくセッショントークンとしての利用を想定しています。

念の為、Auth0の中?というか日本?の方がQiitaに記事を書かれていたので聞いてみました。

qiita.com

やはりセッショントークンのようです。

FIrebaseと一緒というか、むしろ認証フローをある程度隠蔽するFIrebaseに対してOIDCのフローを見せつつそれで取得した ID Token をセッショントークンに使ってと言う混乱の極みであるような気がしてきました。

Kubernetesでも似たような話が?

だいぶ古い記事ではありますが、

qiita.com

このとき、設定した ID トークンの有効期限が切れると認証に失敗してしまいます。一般的に ID トークンの有効期限が切れたら、リフレッシュトークンを使い ID トークンをリフレッシュする必要があり、ユーザエントリで token を使っているとリフレッシュした ID トークンを改めて kubeconfig ファイルに設定し直す必要がありました。 この問題は、kubeconfig ファイルのユーザエントリに token ではなく、OpenID Connect のトークンリフレッシュのフローを実行してくれる auth-provider=oidc を利用することで解決します。

リフレッシュとか言ってます。これ今も同じなんでしょうか。後で見てみます(放置しそう)。

まとめ

セッショントークンなのかそうじゃないのか、どこぞのコーンフレークやら最中やらのお話を思い出させるような展開でございます。

以前もAccessTokenとIDTokenについて整理されていた方にウザ絡みしてしまったことを思い出しました。

qiita.com

ひとまず、現状としては

  • OIDCの定義ではIDTokenをセッショントークンではなく、アサーションとして使うことが想定されている
  • IDaaS的なところではセッショントークンの意味合いでIDTokenと言う名前のトークンが出回っている
  • 有効期限など、両者で要件自体も変わるのでこの2つの意味合いのIDTokenは混ぜるな危険です。開発者はどちらの意味なのかを判断する必要がある(ひどい)

となってそうです。

個人的には 特に紛らわしいAuth0絶対に許さんぞ と思いつつ、 今年も引き続きやっていきましょう。

ではまた。

(追記) 今回の現象は、OIDCのIDTokenに含まれる「ユーザーの属性情報」「IdPでの認証状況に関する情報」「iss,aud,iat,expなどのメタデータ」が

  • ID連携を行うRP側の「認証処理」そのもの
  • ステートレスなセッショントークンを用いたセッション管理

の両方で利用可能なために、同じIDTokenという名前のまま 異なる用途で使われているのだと考えています。 しかし、両者で異なる要件の部分、例えば

  • アサーション : 時刻ずれなどを意識しつつも最低限の有効期限、リプレイアタック防止のためのnonceの値を含む(OIDC IDTokenの仕様)
  • セッショントークン : 有効期限はそれなりに長く、nonceの値は不要、IdP側で更新のために必要な情報を含むかも?

と言ったあたりを利用者が意識できずに用途を間違えて使用すると「ログインセッションが10分で切れるのでやり直し」と言ったおかしなUXとなる可能性があります。 個人的にはセッショントークンとしての用途の場合は別の名前をつけてもらいたい気もしていますが、現状は利用者が気をつけてハンドリングしていくしかないでしょう。

怖くないネイティブアプリケーションにおけるID連携機能を実装するための考え方

f:id:ritou:20191202101535p:plain

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

qiita.com

3日目です。やっていきましょう。

ネイティブアプリのID連携

「今年の汚れ今年のうちに」なんていうフレーズがあります。

記憶が確かではないですが、たしか 7 月ぐらいにどこかの決済サービスによりネイティブアプリのID連携に注目が集まったことがありました。

  • バックエンドサーバーがあるネイティブアプリでID連携(ソーシャルログイン)の機能を実装してた
  • ネイティブアプリからバックエンドサーバーに Identity Provider の識別子、ユーザー識別子の組み合わせを送ることで認証状態にしてた
  • 推測したり総当たりなどで...可能性があった

というお話でした。

この記事では "各Identity Providerが提供している SDK などを使ったり使わなかったりしながら安全に認証機能を実現するための方法" を整理します。

Webアプリとの違い

昨日、こんな記事を書きました。

ritou.hatenablog.com

Webアプリではセッションと OAuth/OpenID Connect の各リクエスト/レスポンスを紐付けることで、安全なID連携機能を実装できます。

f:id:ritou:20191118004418p:plain

この考えをネイティブアプリにも適用していくことを考えましょう。

細かいところはおいといて、Clientをネイティブアプリとバックエンドサーバーに分割します。

f:id:ritou:20191203043602p:plain

ここではまず バックエンドサーバー -> ネイティブアプリ -> Identity Provider -> ネイティブアプリ -> バックエンドサーバー という処理の流れを意識することが重要です。

  1. ネイティブアプリとバックエンドサーバー間では独自でセッションが確立している
  2. バックエンドサーバーがID連携のための "チャレンジ" 的な値を生成し、セッションに紐付ける
  3. ネイティブアプリは Identity Provider に "チャレンジ" を含むID連携のリクエストを送る
  4. Identity Provider は "チャレンジ" の値を含むID連携のレスポンスを生成してネイティブアプリに送る
  5. ネイティブアプリはバックエンドサーバーにID連携のレスポンスを送る
  6. バックエンドサーバーは "チャレンジ" の値を含むID連携のレスポンスを検証する

汎用的な説明とするため、あえて "チャレンジ" という表現をしていますが、"チャレンジ" を引回すといえば最近だと WebAuthn の認証フローも構成要素が違うだけで一連の流れは似ています。認証認可のプロトコルでは基本となるやり方だと覚えておくのが良いでしょう。

ここからは、これを実現するための具体的な実装を整理していきます。

大手 Identity Provider が提示する方法

各 Identity Provider がドキュメントで示している実装方法は微妙に異なります。

自分はこれが最強だと思うという実装方法があっても、Identity Provider がサポートしていない機能は使えません。

特に複数の Identity Provider を相手にする必要がある場合はその差異を自分のところで吸収していく必要があるため、気をつけなければなりません。

この辺りの細かいことを考えたくない人は、最初から Auth0 / Firebase とかに魂を売り渡す方が平和に過ごせるでしょう。

それでは一つずつ見ていきましょう。

LINE : Access Token / ID Token

LINE は Access Token, ID Token を用いた連携方法を案内しています。

developers.line.biz

LINE は各種SDKAccess Token / ID Token を検証するエンドポイントを提供しており、ドキュメントを図にするとこんな感じです。

f:id:ritou:20191203092005p:plain

このやり方では、最初に紹介した バックエンドサーバー -> ネイティブアプリ -> Identity Provider -> ネイティブアプリ -> バックエンドサーバー という処理の流れが実現できません。

Access Token を利用する場合、検証APIを叩くことで

  • Access Token の発行を要求した client_id
  • Access Token の有効期限である expires_in

を取得できるため、攻撃者が異なる Client 向けに発行された Access Token , 有効期限の切れている Access Token を指定しても検知可能です。 しかし、ID Token の nonce 相当の値を検証できないため、バックエンドに送られる Access Token が有効な間は同じリクエストで何度もID連携の認証が可能な状態になり得ます。

LINEはAccess Token を無効化するエンドポイントも用意しており、無効化することで Acces Token の検証に失敗するようになるのであれば、認証だけが目的の場合にはログイン成功後にそれを利用するのも一つの手かもしれません。

一方、ID Tokenには nonce の値を持っていますが、ドキュメントにあるやり方では nonce の値を SDK が作成しています。 これでは SDK を用いて連携フローを開始した際のセッションと ID Token が紐付けられていないため、nonce を用いて一度連携に利用した ID Token を再度利用できないように制限するぐらいしかできません。

より安全にするためには、事前にバックエンドサーバーが nonce の値を生成しセッションに紐づけておき、ネイティブアプリは SDK を利用する際にその nonce の値を指定し、最後にバックエンドサーバーが ID Token の nonce の値をセッションに紐付けられているものと比較します。

f:id:ritou:20191203094857p:plain

ちょっと前に調べたときは iOS Swift SDK あたりで nonce の指定ができなかったのですが、新しいバージョンでは外部で生成した nonce を指定できるようになった模様です。

LINE のID連携を利用しているサービスの開発者の方は参考にしていただければと思います。

Google : ID Token

Google のドキュメントではバックエンドサーバーとのやりとりにID Tokenを使えと案内しています。

developers.google.com

developers.google.com

Warning: Do not accept plain user IDs, such as those you can get with the ...

最初にユーザーIDだけでやるなと警告があります。

こちらも説明では一切 nonce に触れられていないため、LINE と同じ話になりそうですが、SDKsetIdTokenNonce という関数があるのでそれを使って バックエンドサーバー -> ネイティブアプリ -> Identity Provider -> ネイティブアプリ -> バックエンドサーバー という流れを実現できそうです。

Yahoo! JAPAN : Authorization Codeフロー

Yahoo! JAPANでは Authorization Code フローを使い、各種トークン取得をバックエンドで行うように案内しています。

developer.yahoo.co.jp

Authorization Code Grant を使う場合、バックエンドサーバー -> ネイティブアプリ -> Identity Provider -> ネイティブアプリ -> バックエンドサーバー という流れを実現できます。

f:id:ritou:20191203101213p:plain

良さそうですね。

まとめ

Binding の仕組みを生かしたネイティブアプリとバックエンドサーバーのID連携の実装について紹介しました。

Sign In with Apple も ID Token あたりでどうにかできそうですし、この辺りを押さえとけば大丈夫かと思います。

これから同じことを考えるときは

  • Identity Provider がどのようなID連携方法を提示しているか
  • SDK に nonce の値をセットできるか
  • Authorization Code Grant が利用できるか
  • ID Token を取得できるか

あたりを調べて、できるかぎり安全なID連携の実装方法を探っていくのが良いと思います。

認証機能でやらかすとサービスが終わる可能性もあります。我々はあのPayの悲劇を繰り返してはいけません。

認証認可技術 Advent Calendar 2019 明日は @authyasan が OAuth 2.0 の Device Flow について書いてくれそうです。楽しみですね。

意識高めすぎて3日やるとか言ってちょっと後悔しましたが穴開けなくて良かったです。

ではまた。

OAuth 2.0/OpenID Connectで使われるBindingの仕組みについて整理する

f:id:ritou:20191202001445p:plain

おはようございます、OAuth警察を装っている ritou です。

qiita.com

認証認可技術 Advent Calendar 2019 2日目の記事です。

今日もやっていきましょう。

(2020/3/9追記)本投稿の内容をさらにわかりやすく整理された本を @authyasan さんが書かれています。

私もレビューをさせていただきました。本投稿を読んで興味を持たれた方は読んでみてください!

今回の内容

OAuth 2.0/OpenID Connect(OIDC)の新しい拡張仕様の記事などを書くときに

  • 「OAuth 2.0のこのフローにはこういう攻撃が考えられる」... ●●●が置き換え可能
  • 「対策としてこれがあるがこのケースでは使えない、もしくは対策がなくて詰んでいた」
  • 「なので考えたのがこの拡張仕様だ」

という流れにすることがあります。というかそもそも新しいユースケースなどに対して未定義の部分や脆弱な部分にパッチを当てるような拡張仕様の場合、Abstract/Introductionの流れがそうなってたりします。 んで、そういうのを見た人がOAuth 2.0やOIDC全体に対して「攻撃や脅威を整理するところから始めて対策を完全に理解しよう!」となったりするんですが、ちゃんとやろうと思うと RFC6819 みたいになってしまい、特に初学者がやるのはボリューム的にもあんまり筋が良くないと思います。

そこで、今回は 個別の事案じゃなく、OAuth 2.0/OIDCを利用する上で最も重要だと思っている "Binding" に注目して各パラメータや拡張仕様を整理します。

ここで言う "Binding" とは OAuth 2.0/OIDC でWebアプリケーションからよく利用されている認可(認証フロー)において

  • ある値をセッションなどと紐付けて
  • どこかでパラメータとして指定して
  • どこかで検証されることで
  • 一連の流れが同一セッションで行われたとみなせる

と言うのを実現するための仕組みです。

この辺りが理解できると

あたりの話も少し楽になるのではないかと思っています。

とはいえ、特に新しい話ではないですし過去に何度も記事を書いてるので、普通に詳しい人はこんなの読んでないで自分のお仕事に集中してください

セッションとの紐付けについて

この後の説明で「セッションと●●を紐づける」という表現を多用しています。 これはレガシーなWebアプリケーションでいうところの

  • HTTP CookieにセッションIDをもち、それに紐づく値をデータストアに格納する
  • HTTP Cookieそのものに紐づく値を署名つきエンコードや暗号化などをして保持する

というような処理のことです。 アドカレ1日目の記事でも取り上げたセッション管理の話ですが、この記事においても本質ではないので細かい実装などには言及しません。

対象となる OAuth 2.0 / OIDC のフロー

この記事では、OAuth 2.0の各種フロー(Grant Type)のうち、最も一般的な Authorization Code Grant と OIDC の ID Tokenを絡めたあたりを扱います。

大まかな流れをシーケンスにするとこうなります。

f:id:ritou:20191118004418p:plain

最初にフロントチャンネルを用いたやりとりがあります。

  • AuthZ(AuthN) Request : Webブラウザなどのリダイレクトを使ってClient/RPからAS/OPにGETのリクエストが送られる
  • AuthZ(AuthN) Response : AS/OPからClient/RPへの戻りの部分で、Webブラウザなどのリダイレクトを使ったGETもしくはPOSTのリクエストで(Authorization Codeなどの)パラメータが送られる

その後にバックチャンネル、いわゆるサーバー間でのやりとりが行われます。

  • Access Token Request : Client/RPからAS/OPへのPOSTリクエストで(Authorization Codeなどの)パラメータが送られる
  • Access Token Response : AS/OPからのJSONレスポンスでAccess Tokenなどが返される

この一連の流れの中で、バックチャンネルのサーバー間通信ではTLS(SSL)の利用やクライアント認証によってリクエストがClient/RPから送られ、AS/OPから返されたことが検証されます。

それに対して、フロントチャンネルのやりとりのところはいくつかのパラメータの受け渡し方が定義されています。

AuthZ(AuthN) Request では JWT を利用することで AS/OP はリクエストが Client/RP により生成された値であることを検証できます。

f:id:ritou:20191202012355p:plain

AuthZ(AuthN) Response でも同様に、レスポンス自体を JWT にしたりハッシュ値を含む JWT を渡すことで、Client/RP はレスポンスが AS/OP により生成された値であることを検証できます。

f:id:ritou:20191202013519j:plain

ここから紹介するパラメータの中には、 AuthZ(AuthN) Response に JWT を利用するものが出て来ますので意識しておくと良いと思います。

state パラメータ by OAuth 2.0

"#state警察" でおなじみ、OAuth 2.0 でCSRF対策を目的として定義されているパラメータです。

  1. Client/RP は セッションとstateパラメータの値を紐付ける
  2. Client/RP は AuthZ(AuthN) Request に state パラメータを含む
  3. AS/OP は AuthZ(AuthN) Request に含まれた state パラメータをそのまま AuthZ(AuthN) Response に含む
  4. Client/RP は AuthZ(AuthN) Response に含まれた state パラメータがセッションと紐付けられた値と一致することを検証する

f:id:ritou:20191119102956p:plain

HTMLフォームのCSRF対策トークンを Client/RP と AS/OP の間のやりとりで使うイメージで、AuthZ(AuthN) Request と AuthZ(AuthN) Response が同一のセッションに紐づいていることを検証できます。

ここで、AuthZ(AuthN) Responseの検証に注目します。

  • クエリパラメータ/フラグメントとして指定
  • response_mode=post としてPOSTを利用

この場合、state と同時に AuthZ(AuthN) Response に含まれる Authorization Code の値が AS/OP が生成したものであることを検証できません。 悪意のあるユーザーが第3者に向けて発行された Authorization Code の値を取得できた場合、自分の AuthZ(AuthN) Response に指定することが可能です。

それに対して、

  • response_type=code id_token のように JWT 形式の ID Token と一緒に返す : Authorization Code のハッシュ値を含む
  • response_mode=jwt もしくは response_mode=query.jwt のようにJWT形式で返す : state, Authorization Code を Payload に含む

と言う風にJWTを利用する場合は、AuthZ(AuthN) Response 内の Authorization Code が AS/OP により生成されたことを検証できます。

ちなみに FAPI では ID Token に state パラメータのハッシュ値を含む ことも定義されており、その場合は

  • AuthZ(AuthN) Response : AS/OP が生成したものであり、AuthZ(AuthN) Request と紐づいている
  • Access Token Request : AuthZ(AuthN) Request と紐づいている Authorization Code を指定
  • Access Token Response : AuthZ(AuthN) Request と紐づいている

と全体が安全であることを確認できる状態になります。

state パラメータの特徴としては

  • AuthZ(AuthN) Request が AuthZ(AuthN) Response と紐づいていることを確認できる
  • OIDC の ID Token と FAPI の拡張によって、フロー全体をより安全にできる

となります。

nonce パラメータ by OpenID Connect

"nonce があれば state いらず" なんて言われてたり言われてなかったりする、OIDCで定義されているパラメータです。 こちらはリプレイアタック対策のためのパラメータとして定義されています。

この nonce ですが、OIDC の仕様によりAuthZ(AuthN) Request で scope に openid が指定された場合は Access Token Response に ID Token が含まれ、その Payload に nonce の値が含まれます。

まずは AuthZ(AuthN) Request で response_type に id_token を指定しなかった場合 を見ていきます。

  1. Client/RP は セッションとnonceパラメータの値を紐付ける
  2. Client/RP は AuthZ(AuthN) Request に nonce パラメータを含む
  3. AS/OP は AuthZ(AuthN) Request に含まれた nonce パラメータを認証イベントに紐づけておき、Access Token Response に含まれる ID Token に nonce の値を含む**
  4. Client/RP は Access Token Response の ID Token に含まれた nonce パラメータがブラウザセッションと紐付けられた値と一致することを検証する**

f:id:ritou:20191129053811p:plain

この場合、AuthZ(AuthN) Request と Access Token Response が同一セッションに紐づいていることが確認できます。

  • Access Token Response : AS/OP が生成したものであり、AuthZ(AuthN) Request と紐づいている
  • Access Token Request : AuthZ(AuthN) Request と紐づいている Authorization Code を指定
  • AuthZ(AuthN) Response : AuthZ(AuthN) Request と紐づいている(Authorization Code が含まれてた)

というようにフローを逆から見ていくような形で全体の安全性を確認できます。

また、AuthZ(AuthN) Request で response_type に id_token を指定した場合 については

  1. Client/RP は セッションとnonceパラメータの値を紐付ける
  2. Client/RP は AuthZ(AuthN) Request に nonce パラメータを含む
  3. AS/OP は AuthZ(AuthN) Request に含まれた nonce パラメータを認証イベントに紐づけておき、AuthZ(AuthN) Response に含まれる ID Token に nonce の値を含む**
  4. Client/RP は AuthZ(AuthN) Response の ID Token に含まれた nonce パラメータがセッションと紐付けられた値と一致することを検証する**

f:id:ritou:20191129061101p:plain

となり、全てのレスポンスが AS/OP が生成したものであり、AuthZ(AuthN) Request と紐づいていることを確認できます。

  • AuthZ(AuthN) Response : AS/OP が生成したものであり、AuthZ(AuthN) Request と紐づいている
  • Access Token Request : AuthZ(AuthN) Request と紐づいている Authorization Code を指定
  • Access Token Response : AS/OP が生成したものであり、AuthZ(AuthN) Request と紐づいている

nonce vs state

ここまでみて、state と nonce ってだいたい同じじゃねーかと思ったりするわけですが、一般的なWebアプリケーションのセキュリティのお話で

  • CSRF対策 : セッションに一意に紐づく値を利用
  • リプレイアタック対策 : 毎回異なる値を利用

という違いがあるため、仕様では state パラメータはセッションに紐づく値、nonce はワンタイムの値を使うように定義されています。

そして、

  • 実装として nonce をセッションに紐づけておいて検証することが一般的
  • nonce についてAS/OPはFAPIのような拡張を入れなくても OIDC の仕様に沿っていれば上記の検証が可能

という面から、"OIDC を調べて nonce の検証したら state の検証いらないって気づいちゃった" 派が一定数出てくるでしょう。

state と同じ、フロントチャンネルで AuthZ(AuthN) Response に含まれる ID Token の nonce を検証するならば state は不要でしょう 。 ただし、フロントチャンネルでは ID Token を扱わず、バックチャンネルでの Access Token Response までやって ID Token の検証をするのであれば、"もっと早い段階でチェックできるものはする(stateのゆるい検証と併用する)" という考え方もありかなと思います。

code_challenge, code_verifier by PKCE

PKCE は "Proof Key for Code Exchange by OAuth Public Clients" です。

  • Secret を安全に管理できない Public Client(ネイティブアプリやブラウザベースアプリなど)は Implicit Grant ではなく AuthZ Code Grant + PKCE 使え!
  • Confidential Client(Webアプリ)も PKCE でよりセキュアに!

という方向性で広く知られるようになりましたが、今回も他のパラメータの説明に揃えてWebアプリケーションでの利用を前提として見ていきます。

  1. Client/RP は ブラウザのセッションとcode_verifierパラメータの値を紐付ける
  2. Client/RP は AuthZ(AuthN) Request に code_verifier のハッシュ値(もしくはそのまま)である code_challenge パラメータを含む
  3. AS/OP は AuthZ(AuthN) Request に含まれた code_challenge パラメータを認証イベントに紐づけておく
  4. Client/RP は Access Token Request にブラウザのセッションと紐づけておいた code_verifier の値を含む
  5. AS/OP は 認証イベントに紐づけられた code_challenge と Access Token Request に含まれた code_verifier の値を比較(ハッシュ値の検証)し、正しい組み合わせであれば Access Token Response を返す

f:id:ritou:20191129152046p:plain

という流れで、PKCE は AS/OP が AuthZ(AuthN) Request と Access Token Request が同一セッションに紐づいていること を検証します。

全体で見たときには

  • AuthZ(AuthN) Response : Client/RP が確認できるものはない
  • Access Token Request : AuthZ(AuthN) Request と紐づいている code_verifier を指定
  • Access Token Response : AS/OP が生成したものであり、AuthZ(AuthN) Request と紐づいている

となるでしょう。 AuthZ(AuthN) Response の検証ができないということは、指定された Authorization Code を使って毎回 Access Token Request を送るということになりますが、悪意のあるAuthZ(AuthN) Responseを大量に生成されることにより、Client/RPとAS/OPどちらにも負荷がかかってしまうという事態もありえます。 ここは state や nonce(フロントチャンネルのID Token利用)と併用することで、"早い段階でチェックできるものはする" 設計にする方が良いのではないかと思います。

state, nonce, PKCE を導入しても効果が出ないパターン

ちなみに、Client/RP が state, nonce の検証をサボっても処理を進められますが、PKCEは正しいパラメータを AS/OP に送らなければ OAuth/OIDC の処理は進みません。 この点は AS/OP 側としてはフロー全体のセキュリティのことを考えると安心できる仕組みだと思いますが、気にしなければならないのは、state, nonce, PKCE 共にパラメータを生成するのは Client/RP であることです。 以前、Client/RP のパラメータ生成がよくないと意味がなくなるって話をブログ記事に書きましたのでお時間がありましたらどうぞ。

ritou.hatenablog.com

まとめ

OAuth/OIDCの警察ネタでおなじみ、 state, nonce, PKCE を "セッションとの紐付け" という視点から整理しました。

個人的には Client/RP 開発者は AS/OP がサポートするパラメータはなるべく使って検証もがっつりしとけや と言うところですが、 この記事を読んでそれぞれの処理がどう繋がっていて、途中でパラメータを置き換えられた時にどこで検知できるかを意識してもらえたら幸いです。

だいぶ暖まってきたところで、明日(3日目)の 「認証認可技術 Advent Calendar 2019」は...またまた私です。くどいですね。 今回紹介した "Binding" の仕組みを利用してバックエンドサーバーがいるネイティブアプリの場合にどう認証フローを組み立てていけば良いのかを紹介します。DroidKaigi 2020 に応募して散ったネタであります。

ではまた明日。