2020年版 チーム内勉強会資料その1 : JSON Web Token

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

5月下旬ぐらいにチーム内勉強会としてJSON Web Token(JWT)についてわいわいやりました。 その際に作成した資料に簡単な説明を添えつつ紹介します。 このブログではJWTについて色々と記事を書いてきましたが、その範囲を超えるものではありません。

ちょっとだけ長いですが、ちょっとだけです。お付き合いください。それでは始めましょう。

JSON Web Token boot camp 2020

JWTBootCamp2020 001

JWTBootCamp2020 002

今回の勉強会では、JWTについて概要、仕様紹介という基本的なところから、業務で使っていくにあたって気をつけるべき点といったあたりまでカバーできると良いなと思っています。

JSON Web Token 概要

JWTBootCamp2020 003

まずは概要から紹介していきます。

JWTBootCamp2020 004

JSON Web Tokenの定義とはということで、RFC7519のAbstractの文章を引用します。

JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties.

JSON Web Token (JWT) は、2者間で転送されるクレームを表現するためのコンパクトで URLSafeな方法です。

補足します。

JWTBootCamp2020 005

JWTはとにかく色々なデータ、例えば構造化されたものからバイナリデータまでを複数のサービス、システム間でやりとりするため、URLセーフな文字列にエンコードする仕組みです。 また、そのエンコード結果の文字列自体をJWTと呼ぶこともあります。

さらに、JSON Web Signatureという署名をつける仕組みを利用することで改ざん検知が可能になります。JSON Web Encryptionという暗号化を施すことにより、センシティブなデータのやりとりが可能になります。 この2つのうち、よく使われているのを見かけるのがJSON Web Signatureの方でしょう。 ということでここからはJSON Web Signatureについての解説を行います。 (ここからの文中でJWTと書いているものは署名つきのJWSを含みます。)

JWTBootCamp2020 006

そもそも今回、なぜJWTを取り上げたのかにも触れておきます。 誕生のきっかけということで、JWTはOpenIDファウンデーションのWGで行われたID連携やソーシャルログインを実現するための仕組みであるOpenID Connectの仕様策定に合わせ、IETFのJOSE(読みはホゼ) WGにて仕様策定が開始されました。

GoogleAppleでサインインの裏側で動いているOpenID Connectの中で、ID Tokenというユーザーの属性情報や認証時の情報などをやり取りする際にJWTが利用されています。 もともとSAMLというXMLベースのID連携の仕組みで使われていたXML署名という仕組みがあらゆるユースケースに対応できるようにした結果複雑になってしまったという経緯から、JWTはより容易に実装できてコンパクトに表現できるセキュリティトークンを目指しました。 私はこのOpenID Connectの仕様策定にContributorとして参加していたため、この後説明しますが2015年に複数のRFCになるまでの経緯を横目でチラ見していて最近のJWT単独の普及についても注目しております。 ということで今回、ネタとして取り上げました。

JWTBootCamp2020 007

ここからはJWTのユースケースをいくつか見ていきましょう。 サービス、システム間のやりとりに使うという点を意識して、まずはJWTの発行者、そして受信者(利用者)という観点で同一か別かで整理していきます。

JWTBootCamp2020 008

発行者、受信者が同一のユースケースとして、WebアプリケーションにおけるセッションCookieへのJWT適用があります。 (厳密にはmonorithなサービスであれば発行者、受信者が同一となりやすいしょうけれども、規模が大きくなれば発行者、受信者が別になるケースもあり得るでしょう。)

以前からHTTP Cookieにセッション情報を詰め込むという、Cookie Storeとか呼ばれてきたものがあり、以前から様々なWebApplicationFrameworkにてオレオレ署名つきエンコーディングなどで実装されていることはご存知の方が多いかもしれません。これにJWTを利用することができるでしょうというお話です。 さらに、セッションIDをHTTP Cookieに持ってDBなりでセッション情報を管理する実装についてもJWTを適用可能です。セッションIDと発行者、発行日時、有効期限などを合わせて署名つきのJWTに含むことで、ブラウザ上で手元にあるHTTP Cookieの内容をいじって挙動を調べてみるようなことを困難にできます。 処理の流れとしては、Webサーバーがセッション情報に更新があるたびにHTTP Responseとして発行されたものをブラウザがハンドリングし、次のHTTPRequestにてWebサーバーに送ります。

JWTBootCamp2020 009

さらに、WebアプリケーションのHTML Formを用いたPOSTリクエストなどで利用されるCSRF対策トークンにもJWTを適用できます。 この例の方が発行者、受信者が同一である感じが出そうですね。

CSRF対策トークンで重要なのはセッションとの紐付けです。セッションIDそのものではなく鍵を用いて生成したハッシュ値などを格納しておくのが良いでしょう。

JWTBootCamp2020 010

発行者、受信者が別であるユースケースとしては、WebAPIを利用する際の認可用トークンがあります。OAuthのアクセストークンで、発行者であるユーザー認証やアクセス管理を司るサーバー、受信者であるサービスを提供するサーバーが分かれている場合を想像してください。

JWTBootCamp2020 011

よりシンプルなユースケースとして、

  • 署名つきのAPIリクエストにJWTを適用
  • 外部サービスにユーザー情報などを渡す際に署名つきJWTを利用

というものがあります。

JWTBootCamp2020 012

ここまでゆるゆるとユースケースを紹介してきましたが、一旦署名つきのJWTを利用するメリット、デメリットを整理します。 柔軟なデータを文字列にしてやりとり可能なところ、署名とメタデータを組み合わせることで、発行者、受信者、有効期限の検証が可能となる点はとても魅力的だと思います。 一方で、暗号化ではないため、どんな値を含むのかを誰でも知ることができるという点、含まれる情報によるデータサイズの増加には気をつける必要があります。

JWTBootCamp2020 013

JWTが使われている理由として、やはり標準化の強みがあるでしょう。

仕様はRFCになり、ライブラリも各種言語で出揃っています。 OAuth/OpenID Connectをはじめとした標準化プロトコルでの採用実績もありますし、ここまで紹介したように単体で使われるユースケースも増えています。これまで独自の署名つきエンコードを使っていたところからの移行などで利用するケースもあるでしょう。

さらに、最近RFC化されたJWT BCP(Best Current Practice)のように、良くも悪くも枯れてきたと言うか、ここ抑えると安全な実装を実現できると言うのが明確になっている点も様々なところで使われている理由だと思います。

JWTBootCamp2020 014

JWTのユースケースでセッションCookieAPIアクセス時のトークンの例を紹介しました。 このあたりで混乱を呼んでいるものの一つに、Single Page Application のログイン状態の実現方法があります。

この文脈では JWT はエンコードフォーマットだけではなく WebStorage にJWT形式のトークンを保存してAPIアクセスする方式 というような解釈がされがちです。 そのような解釈のまま、HTTP CookieにセッションIDを保存してHTTP Request時に送られるやり方 と比較されたりするんですが、比較の軸が増えてしまって話がまとまりません。

  • セッションIDだけなのか色々詰め込む内包型とするのか
  • 文字列自体に情報を詰め込むのか、ブラウザである程度編集可能なHTTP Cookieの属性値を使うのか

のような比較軸の細かい整理をすることが重要です。

ritou.hatenablog.com

JWTBootCamp2020 015

そして、このCookieとの比較でもよく言及される点として、「JWT=ステートレスでなければならない」「JWTを使う場合は必要な情報を詰め込み、その内容だけで機能を実現させる必要がある」という一種の信仰のようなものがあるのではないかと考えています。

これはとても勿体無いなと感じていて、確かに情報を内包できるという特性は持っていますが、データストアなどを参照するためのキーを持ってはいけないというわけではありません。

例えば単純な識別子を用いたやりとりだったものにJWTを適用するだけで、有効期限や検証という機能を追加できる のです。この考えは既に色々なユースケースで使われているものではありますが、ステートフルなユースケースへも適用できるということを今一度意識してもらえると良いのかなと思っています。

ritou.hatenablog.com

ritou.hatenablog.com

JWTの概要は以上です。

仕様解説

JWTBootCamp2020 016

ここからは、JWTの仕様について簡単に説明していきます。

JWTBootCamp2020 017

JWTに関するコアな仕様が定義されているRFCは5個あります。 その内訳についてですが、

  • 「いつ、誰が、誰に」発行したものか、というようなサービス/システム間のやりとりで必要なメタデータを標準化したもの : RFC7519 JSON Web Token
  • 署名をつけ、検証するために必要なパラメータや実装方法 : RFC7515 JSON Web Signature
  • (今回は紹介しませんが)暗号化周り : RFC7516 JSON Web Encryption
  • 暗号化や署名利用時の鍵の種類、表現 : RFC7517 JSON Web Key
  • アルゴリズム : RFC7518 JSON Web Algorithms

となっています。全てを細かく読んでいくのは時間がかかって大変なので、よく使われている署名つきJWT、JSON Web Signatureについて要点をかいつまんで紹介します。

RFC7515 JSON Web Signature

JWTBootCamp2020 018

JWTBootCamp2020 019

この文字列をご覧ください。皆さんには普通の文字列に見えるかもしれませんが、私にはこう見えます。

JWTBootCamp2020 020

先頭が "eyJ" から始まり、"." で連結されています。

JWTBootCamp2020 021

この文字列の正体としては、RFC7515で定義されている "JWS Compact Serialization" という単一の署名を持つフォーマットであり、世の中で広く使われているのはこのフォーマットです。

仕様ではより複雑なこともできるようになっており、複数の受信者に向けてそれぞれ向けの署名を含むことができる "JWS JSON Serialization" というのも定義されているのですが、使われているのをほとんど見たことがありません。

JWTBootCamp2020 022

改めて先ほどの文字列に戻ります。 最初の eyJ から始まる文字列ですが、Encoded Headerと呼ばれています。

JWTBootCamp2020 023

これはBase64URL EncodeしたJWS Header であり、JWS Header は JSON 形式で表現されます。このJSONの開始タグのあたりで {" から始まる文字列を Base64URL Encodeした時に "eyJ" となるわけです。 内容は自身がどのような種類なのか、署名に関するパラメータを含みます。

JWTBootCamp2020 024

次にPayloadです。2番目の文字列が Encoded Payloadと呼ばれています。

JWTBootCamp2020 025

Base64URL EncodeされたJWS Payloadです。 JWS Headerとは異なり、JWS Payloadはやりとりしたいデータそのものであり、必ずしもJSON形式である必要はありませんが、「誰が」「いつ」「誰に」というような標準的なクレーム(パラメータ)の値が RFC7519 で定義されています。 これと署名検証の仕組みを組み合わせることで「改ざんされていないことが保証できる」文字列となるため、ユースケースはグッと広がります。

JWTBootCamp2020 026

最後にSignatureです。これもEncoded Signatureと呼ばれています。

JWTBootCamp2020 027

Header, Payloadと同様にBase64URL EncodeしたJWS Signatureであり、署名のアルゴリズムはRFC7518、鍵の表現がRFC7517にて定義されています。

JSON Web Signatureの特徴として、署名生成時に利用するBase Stringに対してパラメータのソートなどの正規化が不要であるという点があります。 Twitterなどで使われている OAuth 1.0 という仕組みでは署名つきのリクエストが定義されているのですが、Base Stringを生成するまでの正規化の処理はなかなか面倒です。 JSON Web SignatureではEncoded Header と Encoded Payloadを "." で連結させた値をBaseStringとして利用します。

RFC7519 JSON Web Token

JWTBootCamp2020 028

RFC7519 JSON Web Tokenについて解説します。

JWTBootCamp2020 029

Payloadに含まれるキーバリューの値をJWTクレームと呼びます。

  • JWT自身の識別子である jti
  • host名やサービス内の識別子など、JWT発行者の識別子である iss
  • JWTの受信者、利用者の識別子である aud

という「誰が誰に...」という値が定義されています。

JWTBootCamp2020 030

さらに、「いつ発行され、いつからいつまで有効なのか」を表現するための

  • 発行日時である iat
  • 有効期限 exp
  • この日時以降、有効となる開始日時である nbf

という値が定義されています。

ここまで紹介した値は「汎用的なクレームとして、識別が必要ならば使うと便利だよ。」という意味合いでこのRFC内では基本的にオプショナルです。 JWTを利用するプロトコルや各種プロファイルで「この値を必須として含む」などの定義がされます。

JWTBootCamp2020 031

最初にJWT誕生のきっかけとなったと紹介した「Googleでログイン」を実現するためのプロトコルである「OpenID Connect」でユーザー情報などを伝達するために使われるID TokenはJWTクレームを利用します。

  • issGoogleのアカウント周りを管理するドメイン
  • audGoogleアカウントを受け入れるサービスに client_id として割り当てられた識別子
  • subGoogleアカウントの識別子
  • iss, exp で有効期限を表現

という値がOpenID Connect独自で定義された値と一緒に含まれています。

JWTBootCamp2020 032

ここで紹介した値は全て利用必須なものではなく、これらを利用するプロトコルなどにより決まります。 JWTを扱うライブラリによっては、ここで定義されている値の検証を行うものもあります。 検証の粒度や利用方法については利用するライブラリのドキュメントや実装を追う必要があることに注意は必要でしょう。

RFC7518 JSON Web Algorithms

JWTBootCamp2020 033

RFC7518 JSON Web Algorithmsについて解説します。 ここではJSON Web Signatureの署名生成に利用されるアルゴリズムについて取り上げます。

JWTBootCamp2020 034

一番上にある none については、JWT自体とは別のところで署名生成/検証や暗号化が行われるようなケースで利用します。これではBase64URL Encodeぐらいしかしてないことはここまでの説明でお分りいただけるかと思います。

HSXXX となっているものは、SHA-256, 384, 512といったハッシュ関数を秘密の共有鍵と組み合わせて署名を生成し、同じ計算をして比較することで検証します。 鍵配送が不要な、JWTの発行者、検証者が同一の場合に使いやすいアルゴリズムです。

JWTBootCamp2020 035

RSXXX, PSXXXRSA公開鍵暗号方式ハッシュ関数を組み合わせたデジタル署名を利用するアルゴリズムです。 秘密鍵を用いて署名を生成、公開鍵を用いて署名の検証ができるため、発行者から受信者で公開鍵を渡したり、発行者が公開鍵を提示することで発行者/受信者が別のケースで利用できます。

JWTが使われ始めた頃は、「公開鍵暗号だったら RS256」 という感じで使われていましたが、最近はその状況も変わりつつあります。

JWTBootCamp2020 036

ESXXX公開鍵暗号として楕円曲線暗号を用います。RSAよりも鍵長の短さや処理速度の面で優れているため、最近のプロトコルではこちらを選択するものも見受けられます。

JWTBootCamp2020 037

アルゴリズムと鍵管理の関係は密です。 秘密鍵を公開して後悔しないように気をつけて利用しましょう。

RFC7517 JSON Web Key

JWTBootCamp2020 038

RFC7517 JSON Web Keyについて解説します。

JWTBootCamp2020 039

鍵に関する仕様として、鍵の表現と鍵のセットの表現が定義されています。 アルゴリズムに対応した鍵はどのように表現されるのか、そして鍵のローテーションやアルゴリズム変更時のスムーズな移行を考慮する際に鍵のセットをどう表現するかが重要となります。

JWTBootCamp2020 040

鍵表現のためのパラメータがいくつか定義されています。 このうち重要なのは、鍵の種類である kty, 対応するアルゴリズムである alg, 鍵の識別子である kid です。 他には鍵の利用用途(署名 or 暗号化)である use やさらにその鍵の使い方まで踏み込んだ key_ops があったり、X.509形式の証明書に関連するパラメータがあります。

JWTBootCamp2020 041

HSXXX で利用されるバイナリの秘密鍵kty=octとして表現されます。

JWTBootCamp2020 042

RSXXX で利用されるRSA秘密鍵kty=RSAとして表現されます。長いです。

JWTBootCamp2020 043

公開鍵は少しすっきりと表現されます。alg, kid の値も含まれていますね。

JWTBootCamp2020 044

ESXXX で利用されるECDSAの公開鍵はkty=ECとして表現されます。

JWTBootCamp2020 045

鍵のセットは keys として鍵のリストを持つことで表現されます。 これは先ほども紹介したOpenID ConnectでGoogleから受け取ったJWTの署名検証を行うために公開されている、公開鍵のセットの値です。Appleなども同様に公開しています。

JWTBootCamp2020 046

これらのユースケースとして、有効な鍵情報をjwks_urlとして公開したり、設定ファイルにこの形式で保存するといったものがあります。 弊チームでは設定ファイルなどでPEM形式などを利用していますが、コンパクトに表現できるのであればこのような形式での保存も使っていきたいと思っています。

実装のポイント

JWTBootCamp2020 047

仕様の解説は以上にして、実装のポイントを紹介します。

JWTBootCamp2020 048

ここまで紹介した仕様がRFCになってから5年ぐらいが経ち、良くも悪くも枯れた技術となってきました。 そして最近、RFC8725 JSON Web Token Best Current PracticesとしてJWT実装のベストプラクティスがRFCとなりました。

Qiitaで解説記事を書きましたのでお時間がある方はみてください。

RFC 8725 JSON Web Token Best Current Practices をざっくり解説する - Qiita

JWTBootCamp2020 049

個人ブログにも少し書いたのですが...

JWTBootCamp2020 050

JWTを安全に使っていくためのポイントとして、難しい暗号化処理以外のものとして

  • Payloadに含む情報はよく考えて決める
  • 署名検証処理を確実に
  • 複数のJWTを利用する際は、用途を指定/判別して排他的に検証する

というものが書いてあります。

JWTBootCamp2020 051

Payloadに含む情報について、チーム内の勉強会ではせっかくJWTを使っていてもPayloadに含む情報が足りず、そもそもの目的を満たせていない例を紹介しました(が割愛します)。 これはユースケースに依存するというか、必要な情報が決まるものなのでよく考えましょう。

署名検証については「ちゃんとした」ライブラリを使うことで問題は回避できますが、簡単にこんな攻撃があったよというのを紹介します。

個人的には、最後の用途のあたりが一番重要かと思っているのでスライドのページを割いて紹介します。

JWTBootCamp2020 052

JWTBootCamp2020 053

JWTの署名検証時の脆弱性として有名なのが、alg とSignatureの値を改ざんして検証をパスさせようとするものです。 JWT側の alg に沿って署名検証をしてしまうと、alg=noneを指定してSignature部分を取り去って送られたものを受け入れてしまったり、alg=RS256からHS256に変えられて公開鍵を共有秘密鍵としてハッシュ値をSignatureに指定されたりしたものを受け入れてしまう例が報告されています。

これらを回避するためには、JWTに含まれるalgをそのまま署名検証に使うのではなく、kidに紐付く鍵に紐付くalgを署名検証に利用することが重要です。 その両者が異なる場合はJWTの改ざんが考えられますが、これは署名検証で検知できます。

JWTBootCamp2020 054

ここからはJWTの用途と署名検証について説明します。

ポイントは

  • 用途の表現と指定
  • 鍵の管理
  • 検証

です。

JWTBootCamp2020 055

JWTで用途を表現するために使えるパラメータはいくつかあります。

まずはHeaderのtypです。 例として、Googleが連携済みのサービスにユーザーのセキュリティイベントをJWT形式で通知する仕組みがあって、そこでは secevent+jwt という値を指定しています。 次に、Headerのkidで用途ごとに鍵を分けるという運用もあります。 他には、Payloadにuseusageのような値を含んで用途を表現するというやり方があります。

ritou.hatenablog.com

JWTBootCamp2020 056

これらのどれを使うかについては、利用するサービスの設計やライブラリの状況から柔軟に判断すべきです。

JWTBootCamp2020 057

例えば機能単位で完全に鍵をわけられるような場合はkidに用途を含むことで排他的に検証が可能でしょう。

JWTBootCamp2020 058

鍵の管理には手を出せないがHeaderを自由に指定できる場合はtypの値を利用できるでしょう。ちなみに、ライブラリでこの検証をするものもあります。

JWTBootCamp2020 059

Payloadしか指定できない場合はPayloadに独自の値を指定する必要があるでしょう。 ライブラリでもサポートされていない部分なので、実装漏れがないように気をつける必要があります。

最後に紹介しますが、JWTの署名検証では Header -> Signature -> Payload という順番に扱っていくため、署名検証の処理回数が多い場合はHeaderの参照だけで判断できる方がPayloadの値を参照するよりも負荷がちょっとだけ抑えられるのではと思います。

JWT生成/検証デモ

JWTBootCamp2020 060

最後にJWTの簡単な生成と検証のデモをやってみます。 (予想通り時間もなかったのでざっと流しました。)

JWTBootCamp2020 061

目的としては、簡単なやつでもこれまで紹介した仕様について手を動かすことで理解が深まるかなと言うところです。 業務では、ライブラリを使いましょう。Elixirでは KittenBlue っていうのが便利ですよきっと。

JWTBootCamp2020 062

ここではJWT用のライブラリを使わず、これらの3つの機能を使ってやってみます。 Base64URL Encode/Decode は padding のオプションがあったりするので注意が必要です。

生成

JWTBootCamp2020 063

生成の流れを説明します。 一言で言うと、Header, Payload の値を生成した後、Signature の値を生成して連結します。

1つずつ見ていきましょう。最初はHeaderです。

JWTBootCamp2020 064

ここでJWTに含むHeaderパラメータは3つです。

  • typ ではハンズオンっぽい文字列を用意して、用途を判別できるようにします
  • alg 署名アルゴリズムにはHMAC SHA-256を使います
  • kid 鍵管理を意識しましょうと言ったはずだ

JWTBootCamp2020 065

まずは JSON Encode します。 それを Base64URL Encode すると Encoded Header が出来上がりです。

JWTBootCamp2020 066

次にPayloadです。 ここでは送りたいデータとしてこんな感じのを用意します。 せっかく紹介したんだからRFC7519で定義されている iss とか aud とか exp の値を使えばよかったなと後から思いましたがとりあえずこれでヨシ!

JWTBootCamp2020 067

Headerと同様に、JSON Encode と Base64URL Encode したら Encoded Payload の出来上がりです。 次はSignatureを生成します。

JWTBootCamp2020 068

Signature生成のもとになる文字列は、Encoded HeaderとEncoded Payloadを "." で連結させたものです。正規化不要!簡単ですね。

JWTBootCamp2020 069

今回は鍵として "THIS_IS_SAMPLE_KEY_FOR_JWT_HANDSON" とかを用意して、それで HMAC-SHA256 した値をBase64URL EncodeするとEncoded Signatureの出来上がりです。

JWTBootCamp2020 070

最後に連結させます。 Elixirだとこんな感じです。

iex(1)> encoded_header = %{"alg"=>"HS256", "kid"=>"handson01", "typ"=>"handson+JWT"} |> Jason.encode!() |> Base.url_encode64(padding: false)
"eyJhbGciOiJIUzI1NiIsImtpZCI6ImhhbmRzb24wMSIsInR5cCI6ImhhbmRzb24rSldUIn0"

iex(2)> encoded_payload = %{"Foo"=>"Bar", "Hoge"=>"Fuga"} |> Jason.encode!() |> Base.url_encode64(padding: false)
"eyJGb28iOiJCYXIiLCJIb2dlIjoiRnVnYSJ9"

iex(3)> signature_base_string = encoded_header <> "." <> encoded_payload
"eyJhbGciOiJIUzI1NiIsImtpZCI6ImhhbmRzb24wMSIsInR5cCI6ImhhbmRzb24rSldUIn0.eyJGb28iOiJCYXIiLCJIb2dlIjoiRnVnYSJ9"

iex(4)> encoded_signature = :crypto.hmac(:sha256, "THIS_IS_SAMPLE_KEY_FOR_JWT_HANDSON", signature_base_string) |> Base.url_encode64(padding: false)
"Tp0zcg2nEA1r94EijoymQTTVMwH6iaLoOpxEZf3KcVM"

iex(5)> jwt = encoded_header <> "." <> encoded_signature <> "." <> encoded_signature
"eyJhbGciOiJIUzI1NiIsImtpZCI6ImhhbmRzb24wMSIsInR5cCI6ImhhbmRzb24rSldUIn0.Tp0zcg2nEA1r94EijoymQTTVMwH6iaLoOpxEZf3KcVM.Tp0zcg2nEA1r94EijoymQTTVMwH6iaLoOpxEZf3KcVM"

公開鍵暗号系のalgを利用する場合は、Headerのパラメータで指定してSignature計算のところで適切な署名生成関数を利用します。

生成については割と簡単なので、Qiitaなどで生成を自前でやるのを見かけますが、複数箇所でハードに使っていくとなると共通処理がまとめられ、結果ライブラリを使うのと変わらなくなると思います。

検証

生成ができたので検証もやってみましょう。

JWTBootCamp2020 071

検証の場合、順番が変わります。

Signatureの検証のために先にHeaderの値を参照する必要があり、Payloadの内容を参照するのはSignature検証の後となります。

JWTBootCamp2020 072

生成と逆の手順で、Base64URL DecodeしてJSON Decodeして Header パラメータを取得します。ここで含まれる値であれば、

  1. typ パラメータの値で用途の検証
  2. kid の値が有効なものかどうか
  3. alg の値が kid に紐づいた値と一致しているかどうか

と言う検証を行います。

先ほど述べたように、生成時に alg の値を含まず、省略して3もスキップしても良いでしょう。

JWTBootCamp2020 073

次にSignatureの検証をします。 Base StringはEncoded Header と Encoded Payload を "." で連結したものです。

JWTBootCamp2020 074

kid に紐づく鍵で生成時と同様に値を計算し、Base64URL Encodeした値と Encoded Signature の値を比較します。

JWTBootCamp2020 075

Signatureの検証を終えたら、Payloadの値を参照します。 ここからはJWTを利用するサービスごとによしなにと言うところです。

RFC7519で定義されている iss, aud, exp などのクレームを含む場合はここで検証します。

検証の手順は以上です。

外部サービス

jwt.io というJWTの生成/検証を行う外部サービスがあり、JWTのデバッガがついています。

jwt.io

ここまでで生成したJWTを入れて鍵情報も入れてやると、署名検証を行い成功したことがわかります。

JWTBootCamp2020 076

公開鍵暗号を使うalg=RS256も紹介したかったですが時間がなさそうなのでやめました。 興味がある方は試してみてください。質問などありましたらいつでもどうぞ。

JWTのデバッガは、他にもmicrosoft製のjwt.msなどがあります。 jwt.ms: Welcome!

外部サービスなので、本番環境で単体でAPIアクセスに使えるようなJWTを入力したり、業務で扱う秘密鍵を指定しての署名検証などは避けましょう。

まとめ

  • JSON Web Tokenの概要を紹介しました
  • 様々なサービスで使われているJSON Web Signatureと関連する仕様を紹介しました
  • 実装で気をつけるべきポイントを紹介しました
  • ハンズオン的にJWTの生成/検証を行う方法を紹介しました

初学者向けにまとめたつもりでしたが、もっとユースケース毎にこんな情報をこんな感じで含むといいよねみたいな話や実装についてももうちょっと紹介できると良かったかなとちょっと反省しています。


と、こんな感じでした。 今後もユーザー認証とかリソースアクセスとか、おじさんの守備範囲でその3ぐらいまではやってみたいと思っています。

speakerdeck.com

ではまた!

「ID TokenをAuthorization Headerにつけて送る」というお作法について思うところ

こんばんは、ritouです。

f:id:ritou:20200517193756p:plain

ID Tokenがやりとりされている背景

ちょっと前にこんな話がありました。

blog.ssrf.in

この id_token が JWT になっていますので、これを Authorization: Bearer $ID_TOKEN というヘッダにして oauth2-proxy で保護されているアプリケーションへ送信するだけです。

docs.aws.amazon.com

Authorization ヘッダー (または、オーソライザー作成時に指定した別のヘッダー) に ID トークンを含めます。

この「ID TokenをAuthorization Headerに指定して保護されているっぽいリソースにアクセスする行為」は一体何なのかというお話です。

ある有識者はOAuth 2.0のProtected ResourceをID Tokenで保護することについての投稿をしました。

medium.com

いわゆる3legged(ユーザー、AuthZ/Resource Server, Client)でこういう実装をすると上記リスクがあるのは当然でしょうが、世の中にはセッショントークンとしてID Tokenと名乗る物を使うユースケースが存在します。

ritou.hatenablog.com

IDaaSのような認証基盤を利用するとき、

  • (セッショントークンである)ID TokenをID Token発行元のサービスに送り、何らかの操作を行う

という処理が発生するのだろうと考えています。

OSSなものでそのような実装が行われる際には「これってリソースアクセスでは?ID TokenじゃなくAccess Tokenでやるべきでは?」なんて意見が出るようですが、「いや、こっちはリソースサーバーじゃねーし(セッショントークン相当のID Token送るでヨシ!)」みたいな感じになってるようでした。

カオスなままで良いのか?そもそもセッショントークンの扱いとは?

上記のような現状について「リソースサーバーじゃないからそうか。仕様で定義されていない物ならしょうがないか」となっている意見も見受けられます。 仕様がないからしょうがない。 特定の小さな領域でのみの作法に止まるならばそれでも良いでしょう。 しかし、この話はIDaaSを使わない、単純な1st PartyなNativeApp/SPAとバックエンドサーバーの実装にも影響するのではないかと気になっています。

例えばですが、

これはどうでしょうか。

1st Party なアプリとバックエンドでやりとりにOAuth 2.0を適用!ってのをやったことがある開発者であれば、その間は Access Token を用いたAPIアクセスとなるでしょう。 しかし、今後上記のようなIDaaSのお作法から入った開発者の場合、ID Tokenを使ってAPIアクセスを行うという可能性もあるのではないでしょうか? 暗黙的にこれらの2つの実装が広く使われ始めると、開発者の混乱を生みそうです。そうでもない?

この辺りで自分の認識はどうかというと、むかーし、こんな記事を書きました。

ritou.hatenablog.com

この時は今よりも舌ったらずなので内容もいまいち何言いたいかわからんし、今では説明に使うべきではないと思っている「認証」「認可」なんていうワードを軽々しく使っているため皆さんの反応もパッとしない感じでしたが、ブラウザとWebサーバーとのやりとりもざっくりいったら特殊なクライアントとリソースサーバーとのやりとりと同じような意味合いであり、セッションクッキー/セッショントークンは「凄まじい権限を持った」アクセストークン相当であろうというお話です。 当時はブラウザはUserAgent何だからClientではない、みたいなご指摘をもらったりもしましたが、SPAの場合はClient相当がちゃんといるのでなおさら意識しやすくなったのではないかと思ったりもします。

現状に対する自分の考えるあるべき姿としては「どんなユースケースであれ、ID TokenじゃなくAccess Tokenでやれ」という考えであり、中身に含まれる情報の話や処理についてなど、これから色々足りない部分を補足していきたいところであります。

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のローテートにもデータを増やさずに対応可能

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

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

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