OAuth 2.0フローとOpenID Connectフローとの関係

 

川崎貴彦氏:今度はOpenID Connectのフローの説明をします。

OAuth 2.0では認可エンドポイントを使うフローとして、認可コードフローとインプリシットフローの2つがあります。認可コードフローの認可リクエストとインプリシットフローの認可リクエストは、どうなっているかというと、まずresponse_typeというところが違います。response_typeがcodeの場合は認可コードフロー、tokenの場合はインプリシットフローです。

OpenID Connectはこのresponse_typeの仕様を拡張したんですね。 どう拡張したかというと、まず選択肢としてid_tokenを指定できるようにしました。さらに何もないnoneも追加。そして、code、token、id_tokenを任意の組み合わせで指定可能になっています。

一番多い場合はこれらを3つまとめて指定できるというわけのわからないことになっていて、さらに今度はscopeパラメータが含みうる値としてopenidというのを定義しました。なので、このresponse_typeの値が何であるかと、scopeパラメータにopenidが含まれているかどうかで、OpenID プロバイダーの動作が変わってくる。これで返されるトークンの種類が決まります。

OpenID Connect Coreという仕様の3.1.2.1のAuthentication Requestを見ると、scopeの値としてOpenID Connectリクエストはopenidというscopeを含める必要がある。openidというscopeの値が含まれていない場合は、その動作は未定義であるという感じです。

仕様の「the behavior is entirely unspecified」という文章に、私は正しく動かないという解釈をしました。しかし世の中には正しく動くケースを含むと考える人もいます。これはOAuthのメーリングリストで議論を吹っ掛けてこういう話が出たんですけど、正しく動いてよいなら「MUST contain」と仕様書に書く意味がないと僕は思います。

あるかどうかをチェックしなくていいのだったら認可サーバーの実装でscopeにopenidが含まれているかどうかチェックする必要がなくなる。なのでAuthleteの実装としては、response_typeがid_tokenが含まれている場合、scopeにopenidが含まれていなければ必ずエラーしています。

response_typeがcode、token、code token、noneの場合、scopeにopenidが含まれていなくてもエラーにはならない。要はresponse_typeにid_tokenが含まれていない場合はエラーにならない実装になっています。だからここら辺は認可サーバーの実装者の哲学とかが反映されたりするんですよね。仕様をどう解釈しているか。Authleteはこういう実装になっています。

OpenID Connectの仕様になっているので、組み合わせ的にはこういうことが発生し得るんですね。

response_typeの値とscopeの値がopenidを含むかどうか。これはここに組み合わせ爆発みたいになっていますが、一番多いのがこの7番で、response_typeにcodeやid_token、tokenも含まれている。そしてscopeにopenidが含まれている状態。こんな感じの組み合わせで変わります。

response_typeの値の組み合わせ

ここがどうなるのかを細かく見ていきましょう。まず1番はresponse_typeの値がcodeになっていてscopeにopenidを含まない場合。これはOAuth 2.0の認可コードフローとまったく同じ動作です。

クライアントが認可リクエストを投げて認可エンドポイントがユーザー認証をして、認可もゲットします。その結果、サーバーが認可コードを発行する。認可コードの発行を受けたクライアントアプリはトークンリクエストを行い、アクセストークンを取得するというフローです。

次にresponse_typeはcodeですが、scopeにopenidを含む場合はどうなるかというと、前半のところは一緒になります。認可リクエストの結果、認可コードが発行されますが、トークンエンドポイントにトークンリクエストを投げるところも一緒。これは結果が違って、アクセストークンに加えてIDトークンも発行される。openidを含むか含まないかで違いが出てきます。

次にresponse_typeがtokenでscopeにopenidがない場合。これはまったくOAuth 2.0のインプリシットフローと同じ動作。認可エンドポイントから直接アクセストークンが発行され、トークンエンドポイントは使わないというフローです。OpenID Connect Coreの仕様書にも、response_typeがtokenの場合は「自分たちのの知ったことではない」と書いてあります。

次にresponse_typeがid_tokenの場合。仕様的にscopeにopenidというのは必要です。認証・認可リクエストを認可エンドポイントに投げてユーザーが認証・認可すれば、結果IDトークンだけ発行されます。IDトークンだけが必要な場合はこれでぜんぜんOKです。

次にresponse_typeに複数、id_tokenとtokenの2つが含まれている場合。id_tokenが含まれているのでscopeにopenidは必須。何が起こるかというと、この認可エンドポイントからIDトークンとアクセストークンが発行されるだけで一連の動作は終わりです。トークンエンドポイントは使わない。response_typeがだいたい認可エンドポイントから何が返ってくるかを表しているようなものになります。

次にresponse_typeがcodeとid_tokenで、scopeにopenidが含まれている場合。これは認可エンドポイントから認可コードとIDトークンが発行される。認可コードが発行されたのでそれを持ってクライアントはトークンリクエストをするとアクセストークンが発行されます。ついでにまたIDトークンも発行されるんですね。なぜかというと、認可リクエストのscopeにopenidが含まれているので、トークンエンドポイントからもアクセストークンが発行されるからです。

IDトークンが2つ発行されているんですけど、この内容は2つ一緒でもいいし違ってもいいと仕様書には書いてあります。そこは実装の自由ということですね。

次にresponse_typeがcodeとtokenで、openidがscopeに含まれていない場合は、認可エンドポイントからアクセストークンと認可コードが発行されます。認可コードを使ってトークンエンドポイントにリクエストをしてアクセストークンが発行される。ここではopenidがscopeに含まれていないので、IDトークンはどこでも発行されませんが、アクセストークンは2つ発行されています。

仕様書上、このアクセストークンは同じ権限をもつアクセストークンを発行してもいいし、違ってもいい。それは実装の自由となっています。

次に、今度は同じcodeとtokenで、scopeにopenidが含まれる場合はアクセストークンと認可コードがエンドポイントから発行されて、認可コードを使ってトークンエンドポイントにトークンリクエストを投げます。結果としてアクセストークンに加えてIDトークンも発行されるという流れです。

さて、いよいよ一番複雑なやつですね。response_typeにcode id_token tokenが含まれる場合で、openidはscopeに含めないといけない。結果は認可エンドポイントから3つ、IDトークンとアクセストークンと認可コードが発行。トークンエンドポイントにトークンリクエストを投げた結果、IDトークンとアクセストークンが発行されるという流れになっています。

最後はresponse_typeがnoneです。何も発行しないので結局認可リクエストを投げても何も発行されないで終わります。なぜこんなフローが用意されているのかというと、仕様書的には例としてエンドユーザーがログイン済みかどうかをチェックしたいときにに使う感じです。promptというリクエストパラメータがあり、それと組み合わせて使うようなことが書かれています。

今まで見てきたように複雑ですが、整理するとresponse_typeにcodeというものが含まれていた場合、認可エンドポイントから認可コードが発行されてトークンエンドポイントからアクセストークンが発行される。この条件下にscopeにopenidが含まれている場合はトークンエンドポイントからIDトークンが発行されます。

次にresponse_typeにtokenが含まれている場合。認可エンドポイントからアクセストークンが発行されます。response_typeにid_tokenが含まれていれば認可エンドポイントからIDトークンが発行。実装的にはresponse_typeがnoneならば何も発行されない。実装的にはこういった箇所に注意してやっていきます。ここまでがOpenID Connectのフローです。

JWK Set Documentとは

次にJWKという仕様について説明します。JWKはJSON Web Keyの略です。その仕様書はJWK Set Documentというものの仕様を定義をしていて、これはJSON Web Keyの集合になります。形式的にはJSON Web Key Setは、keysというプロパティをもつJSONで、その値は配列です。

この配列の中にJWKがいくつも並んでいるという形式になっています。このJWKというのはフォーマットがいくつか、具体的にはこんな感じで、。例えばこの一番上のやつの場合はktyにECと書いてあり、楕円曲線系の暗号アルゴリズムであるElliptic Curveを使っています。

ktyがECの場合、それに関する固有のパラメータ群がこんなふうに入ってきます。useというのはオプショナルですけれども、例えばencと書いてあるのでこのキーを使うときは「暗号処理に限りますよ、署名では使わないでね」ということが書かれたりしています。kidというのはこのキーのIDを識別するためのものです。ここでは今単純に1としていますけど。

下のJWKはktyがRSAなので、RSA系の暗号アルゴリズムを使っている。そっちのRSA系のパラメータはそれ固有のn、eというのが入っています。kidは2011年の4月29日みたいなのが書いてあり、kidの値というのはフォーマットではとくに決まっていないので自由に決めてくださいというところですね。これがJWK Set Documentです。

IDトークンの署名

なぜこんな仕様があるかというと、IDトークンの署名のことを考えます。認可サーバーは鍵のペア、公開鍵と秘密鍵を用意しておきます。IDトークンの子分みたいなものを作って、これに対して署名をするときは、サーバーの秘密鍵で署名します。生成したIDトークンを認可エンドポイントもしくはトークンエンドポイントを経由してクライアントアプリケーションに渡します。

クライアントアプリケーションというのは署名付きのIDトークンを取得します。この状況でサーバー側はさっき説明したJWK Set Documentを用意して、この中には公開鍵のほうだけ情報を含めておきます。情報を含めておいて、この情報をJWK Set Documentのエンドポイントから情報を公開するかたちです。

このエンドポイントで公開されているので、クライアントアプリケーションはJWK Set Documentの取得と公開鍵が獲得できます。そのJWKを使ってIDトークンについてる署名を検証することが可能です。なので認可サーバー、OpenIDプロバイダーを実装するときはIDトークンを発行すれば終わりではなくて、JWK Set Documentを公開するエンドポイントも合わせて実装しないと意味がありません。

なぜなら、署名を付けてIDトークンを発行してもクライアントがそれを検証する術がないと無意味だからです。

IDトークンの暗号化

次にIDトークンの暗号化を考えます。これは何が起こるかというと認可サーバーは先ほどの手順でIDトークンに署名をして今度はこれを暗号化する。2段階暗号処理をするので共有鍵をランダムに生成して、この共有鍵で暗号処理します。

そして今度はクライアント側の鍵も必要です。クライアント側で公開鍵と秘密鍵を作ってもらい、このクライアントの公開鍵をJWKとしてサーバー側に公開してもらう。サーバー側はそのJWK Set Documentを獲得して、その公開鍵で共有鍵を含めて暗号化する。この状態で暗号化されたIDトークンをクライアントアプリに渡します。

これは自分が持っている秘密鍵で復号すると共有鍵が出るので、その共有鍵を使ってIDトークンを復号するという処理が必要です。だから暗号化するためにはクライアント側の公開鍵を発行する仕組みが必要になります。

そのサーバーはクライアントのJWK Set Documentのありかをどうやって知るのか? クライアントアプリの属性にjwksというものが仕様書で定義されています。その値として直接JWK Set Documentをサーバーに登録しておけば、サーバーはクライアントのJWK Set Documentを認識できるんですね。

もしくはクライアントアプリの属性のjwks_uriを使って、その値にJWK Set Documentの置いてある場所を書いておけばサーバーはそこにJWK Set Documentを取りに行くことが可能です。ここら辺はOpenID Connect Dynamic Client Registration 1.0という仕様書に書いてあります。

次にIDトークンの署名・暗号化で使うアルゴリズムはどうやって決められているんですか?  という話です。これもクライアントアプリのメタデータがあって、id_token_signed_response_algで署名に使うアルゴリズムを指定する。仕様書的にはたくさんの種類の署名アルゴリズムがあります。

暗号化については、2種類の暗号化アルゴリズムが存在。それぞれクライアントメタデータで指定することになっています。こういうのを何らかの方法でクライアントのメタデータとして設定できる仕組みが認可サーバー側に必要。例えばAuthleteの実装の場合はクライアント管理画面でこういうのが選べるようになっています。

例えばIDトークンの署名アルゴリズムとしてここから選んでください。あとはそのIDトークンのキーの暗号化アルゴリズムで選んでくださいと指定する。加えて、クライアントのjwksとかjwks_uriを設定する画面も用意されています。ここら辺はとくにIDトークンの暗号化処理をやろうとするとクライアントの鍵管理もしないといけないので本当に面倒くさくなります。

なので、認可サーバーの実装としてはIDトークンの署名はできても暗号化はできないという実装はけっこうあります。認可サーバーの製品を選ぶときにIDトークンの暗号化はできるのかどうかチェックしたほうがいいです。

次にそのアルゴリズムが選べるかどうか。IDトークンの署名アルゴリズム、例えばRS256に決め打ちという実装も世の中にはかなりあります。決め打ち実装にしてしまえば、実装は楽です。しかし、他のものが選べなくなります。

例えばFinancial-grade APIという、よりセキュアな仕様だと逆にRS256は使ってはいけないとなっているんです。ES256かPS256のどちらかでないといけないと書かれているので、そこをサポートしていない認可サーバーを選ぶと、将来的にFinancial-grade APIのサポートは無理になってしまいます。そこはアルゴリズムの注意点ですね。

PKCEについて

次にPKCE、ピクシーと読む仕様の説明です。これは2015年9月に発行された仕様で、セキュリティ的に非常に重要、かつ、今となっては認可サーバーとしてサポート必須の仕様になっています。

この仕様が策定された背景として、最初にある攻撃が報告されました。どういうことが起こったかというと、資料の一部が英語で申し訳ないんですけど、左側のEnd Deviceというのがスマートフォンだと思ってください。これでその中にあるアプリが認可リクエストを投げます。その認可リクエストというのはそのスマートフォンのOSのオペレーティングシステムのブラウザに渡ってきます。

そのブラウザ経由で認可リクエストが認可サーバーに投げられると、認可コードが発行されて発行された認可コードはそのアプリに渡される。しかし、この認可コードを横取りする攻撃が発生。この悪いアプリが横取りをして、認可コードを使いトークンリクエストを行なってアクセストークンをゲットしてしまうことが起こりました。

赤い線が認可コード横取り攻撃と呼ばれている攻撃です。これを何とかする必要があります。本当は認可リクエストを行ったのはこのアプリなのに、トークンリクエストを行ったのは別のアプリになっている。認可サーバー側としては認可リクエストを投げた人とトークンリクエストを投げてきている人が一致しているかを確認が必要な事態になりました。それがPKCEという仕様ができたきっかけです。

この攻撃を防ぐための基本的なアイデアは、まずクライアントアプリがランダムな値を生成します。そのランダムな値に対するハッシュ値を計算する。認可リクエストを送るときにハッシュ値を含め、リクエストを受け取った認可サーバーはそのハッシュ値を覚えておきます。

次にクライアントアプリケーションは引き続きトークンリクエストを投げたときに、今度はハッシュ値ではなくてランダム値も渡す。認可サーバー側ではやってきたランダム値のハッシュ値を計算して、それが事前に保存しておいた値と等しいかどうかを調べます。等しければその認可リクエストとトークンリクエストを投げてきた人は一緒ということが確認できる。これが基本的なアイデアです。

具体的にはどういう感じに見えるかというと、まずクライアントアプリでcode verifierというものを生成して、このcode verifierのハッシュ値を計算します。ハッシュを計算するアルゴリズムとして今2種類定義されていて、S256とplain。セキュリティ的にplainを選んでしまうとほぼ意味がないのでS256を選んでハッシュをしますが、計算した結果をcode challengeと呼びます。

このcode challengeを認可リクエストに含める。具体的には、code_challengeという認可リクエストパラメータとcode_challenge_methodという認可リクエストパラメータが追加になります。

認可サーバー側はこの認可リクエストを受け取って最終的に認可コードを発行する。この認可コードというのは認可サーバーのデータベースに保存しておきます。保存するときに指定されたcode_challenge_methodと渡されたcode_challengeというのを紐づけて覚えておくことを認可サーバー側でやる。そして、認可コードだけを発行します。

この応答としてはcode=authorization codeと書いてあるので、見た目は今までと変わりません。ただ、ここで発行された認可コードというのはcode challengeとcode challenge methodに紐づいている。これが認可レスポンスです。

この次にクライアントアプリケーションがトークンリクエストを投げます。今度はこのcode verifierというもともと作ったランダム値と、認可コードの2つを、認可サーバーに投げる。トークンリクエストのときに今までのauthorization codeに加えてcode verifierというのを加える。

認可サーバーは何をするかというと、トークンリクエストから認可コードとcode verifierを取り出して、認可コードを基にその認可コードが存在するかなというのをデータベースで調べます。調べたらその認可コードはちゃんとデータベースに保存され、そこにはcode challenge methodとcode challengeの値もある状態です。これをデータベースから取り出します。

保存していたcode challenge method、例えばS256。それともらってきたcode verifierを使ってcode challengeを認可サーバー側で計算します。この計算したcode challengeが保存してあるcode challengeと一緒であるかをチェックする。これが一緒であれば認可リクエストとトークンリクエストを投げてきたクライアントは一緒だとわかります。

それでアクセストークンを生成して返す流れが、PKCEの仕組みです。昔は、認可コードフローでパブリッククライアントの場合はPKCEを使いましょうというルールでした。最近のOAuth 2.1やBest Current Practiceとかそう言われている世界では「もうクライアントの種類に関係なくPKCEを必須にしましょう」という流れです。

なので「この条件のときだけPKCEを使おう」ではなく「PKCEは必須」という話になっています。