OpenID Connectの仕様を改めて読み返してみる

はじめに

この記事は、「Digital Identity技術勉強会 #iddance Advent Calendar 2023」10日目の記事となります。
https://qiita.com/advent-calendar/2023/iddance

OpenID Connect ムズカシイ…

なんとなく、OpenID ConnectやOAuth2.0の仕様を勉強して、わからないなりに手を動かし、ググりまくり、実際にRP・OP側の実装経験らしきものを積んでくると
お、これで自分も基本的な所は理解できてきたかな? などと軽い気持ちで思い出した頃に、実装ミスや答えに窮する質問を受けて落ち込むもの。
自戒の意味も込めて、一般に言われていることが仕様上はどう記載されているかやTipsも含めて残していきたいと思います。

今回読んでいくのは「OpenID Connect Core 1.0」です。

個人的にそうだったんだ~と思った個所を雑多に集めて関連性は全然なくなっちゃいましたが。。

1. httpスキーマのURLをredirect_uriに指定できるか

なかなか本運用をしているRPがhttpスキーマで動作していることは少ないと思いますが、実情、開発環境ではRP側の制約などで指定したい、と思われるケースも。
Tokenエンドポイントへのリクエストは置いておいて、
Authorization Code Flowを定義した「3.1.2.1. Authentication Request」のredirect_uriパラメータの説明は下記のとおりです。

When using this flow, the Redirection URI SHOULD use the https scheme; however, it MAY use the http scheme, provided that the Client Type is confidential, as defined in Section 2.1 of OAuth 2.0, and provided the OP allows the use of http Redirection URIs in this case. The Redirection URI MAY use an alternate scheme, such as one that is intended to identify a callback into a native application.

「Client Typeがconfidential(=client sercretを安全に管理できる)であり、かつOPがhttpの利用を容認する場合に限り、httpスキーマを利用してもよい」ということなので、条件付きで利用が可能という記載にはなっています。
一方Implicit Flowを定義した「3.2.2.1. Authentication Request」では下記のような記載です。

When using this flow, the Redirection URI MUST NOT use the http scheme unless the Client is a native application, in which case it MAY use the http: scheme with localhost as the hostname.

「このフローを使用する場合、Clientがネイティブアプリケーションでない限り、redirect_uriにhttpスキーマを利用してはならない(ネイティブアプリケーションの場合はホスト名を「localhost」とした上でhttpスキーマを利用してもよい)」
ということなので、フロントを経由してリダイレクト先にトークンが渡るImplicit Flowの場合はhttpスキーマは利用しないように、という記述になっています。

結果的に、OPでこの辺りのRP側の細かな条件が判断できないという背景もあると思いますが、OP製品側でhttpスキーマはクライアント設定時のリダイレクトURLに無条件で登録できない、という仕様になっているケースもあるので、Authentication Requestでも "httpスキーマは基本利用しない" という前提の方が齟齬が起きにくいと思います。開発環境であっても。。

2. フローごとのnonce定義

こちらも改めてですが、nonceはIDトークンのリプレイアタック対策として利用されるパラメータです。
つまり、正規のRPに渡したはずのIDトークンが何らかの方法で窃取された場合に、IDトークン内にセッション内で発行したnonce設定しておき、RP側でトークン内の値とセッション内の値の一致確認をすることで、セッション外で窃取されたトークンであるかどうかを確認します。

nonceもAuthorization Code Flowを定義した「3.1.2.1. Authentication Request」では 「OPTIONAL」 として記載されています。

一方、Implicit Flowの「3.2.2.1. Authentication Request」ではというと下記の通り。

REQUIRED. String value used to associate a Client session with an ID Token, and to mitigate replay attacks. The value is passed through unmodified from the Authentication Request to the ID Token. Sufficient entropy MUST be present in the nonce values used to prevent attackers from guessing values. For implementation notes, see Section 15.5.2.

Implicit Flowの場合は 「REQUIRED」 で利用が必須となっています。
redirect_uriと同様、リダイレクト先にフラグメントでIDトークンが付与される=フロントエンドの通信を経由してトークンが受け渡されるImplicit Flowではバックエンド通信が前提のAuthorization Codeフローより窃取のリスクが高まるため、nonceの検証でカバーする必要があります。
Implicit Flowの「3.2.2.1. Authentication Request」では下記のような形で、基本的にはAuthorization Codeフローの定義と同一であるとしたうえで

Authentication Requests are made as defined in Section 3.1.2.1, except that these Authentication Request parameters are used as follows:

  • response_mode(処理フローを決定)
  • redirect_uri
  • nonce

の3項目は異なる定義として記載がされています。
Authorization CodeフローとImplicitフローはシーケンスの差としては理解した気になっていつつ、
個々のパラメータ差異までは深く理解できていなかったな…と読み返して思いました。

3. 「alg : none」問題

JWTの署名検証時には、ヘッダーに記載されているalgパラメータ通りのアルゴリズムで正しい署名がされているか検証し、トークン発行元の正当性を確認する必要があります。
ただ、本当にこの通りに検証してしまった際に発生するのが 「none」 の問題。

とした際に、「トークンに記載されているalgの値を元に署名検証方法を選択したのち検証を実施する」となると、
書き換えられたnoneが優先されてしまい、そもそも署名検証が飛ばされてしまう(本来はヘッダーを書き換えた時点で署名検証は失敗するはずなのに)
という問題が発生する可能性があります。

対策としてはOPの署名アルゴリズムを確認したうえでRPが検証を実施する、ということになりますが、 Authorization Code Flowを定義した「3.1.2.1. Authentication Request」では下記のような記載。

ID Tokens MUST NOT use none as the alg value unless the Response Type used returns no ID Token from the Authorization Endpoint (such as when using the Authorization Code Flow) and the Client explicitly requested the use of none at Registration time.

仕様上はnoneの利用は禁止されているんだ!と発見しつつ、
トークンがバックエンドで取得されるAuthorization Codedフロー等の利用勝つClientが明示的にnoneの利用を要求した場合は例外とも書かれています(大分レアケースな気がしますが)
署名検証にはライブラリを利用することがほとんどだと思いますが、RPが利用しているライブラリによっては通ってしまう、ということもあるとのことでRP側でも確認が必要なポイントです。

4. subにメールアドレスが利用されるケース

色々な所で言われる「識別子にメールアドレスを使う」問題。
もちろんメールアドレスはUniqueではありますが

  • 変化しうる(別のメールアドレスに変更される可能性がある)
  • 再利用されうる(ユーザを作り直した際に、同じメールアドレスが利用される可能性がある)

という点で、本来非常に扱いづらい属性です。
企業内だと自分のメールアドレスが変わることはあまりないかもしれませんが、コンシューマの文脈だとそれ相応に発生することが考えられますし、
OpenID ConnectでSSOを利用していると余計に 「統合ID側でメールアドレスを変更するときにはOP側のメールアドレスも必須で変更をかけなければならない」 という事態にもなりかねません(到達性確認していなかったりするともっと悲しい)。

このあたりも仕様では「5.7. Claim Stability and Uniqueness」に下記の通り記載されています。

All other Claims carry no such guarantees across different issuers in terms of stability over time or uniqueness across users, and Issuers are permitted to apply local restrictions and policies. For instance, an Issuer MAY re-use an email Claim Value across different End-Users at different points in time, and the claimed email address for a given End-User MAY change over time. Therefore, other Claims such as email, phone_number, and preferred_username and MUST NOT be used as unique identifiers for the End-User.

例としてメールアドレス、電話番号、ニックネームのような時間の経過により変更の可能性がある属性は一意性を保証できず、識別子としては利用してはいけない、とのことですが、
とはいえ・・・このあたりはRP側の仕様に拠る部分が大きく、まだまだメールアドレスが識別子となっているRPは多いため、実情は対応が難しい部分であると思います。
使わざるを得ないなら変更時のケアも考慮する という所も踏まえて、長期的なシステムへの負荷等含めて検討が必要な個所になってくると思います。
この辺りも仕様に書いてたこと自体が気づけていなくて、なるほど…になりました。

5. リフレッシュトークンとpromptパラメータ

最後はリフレッシュトークン関係の話題。
scopeに「offline_access」を指定することでOAuth2.0のリフレッシュトークン発行を要求することができます。
仕様では発行に際してpromptパラメータの利用方法について言及があります。
「11. Offline Access」での記載は下記の通り。

When offline access is requested, a prompt parameter value of consent MUST be used unless other conditions for processing the request permitting offline access to the requested resources are in place. The OP MUST always obtain consent to returning a Refresh Token that enables offline access to the requested resources. A previously saved user \consent is not always sufficient to grant offline access.

offline_accessを要求する際は

  • リクエストするリソースに対する「offline_access」の許可に必要な何らかの他の条件を満たさない限り、promptパラメータに「consent」を指定しなければならない
  • OPはリフレッシュトークンを発行する際は必ず明示的な同意を得なければならない
  • ユーザーの事前同意は offline access の許可には常に十分とはならない

ということで、リフレッシュトークンは一般的に有効期限が長く、一度発行した後は明示的に取消されるまでトークン更新に利用ができるため、発行時に明確にエンドユーザに同意を取ることが必要、と記載がされています。

ここで「prompt」についても言及がされていますが、
promptについては「3.1.2.1. Authentication Request」で次のように記載されています。

OPTIONAL. Space delimited, case sensitive list of ASCII string values that specifies whether the Authorization Server prompts the End-User for reauthentication and consent.

エンドユーザへの「再認証・同意要求」をコントロールするパラメータとのことで、以下の値が定義されています。

概要
none いかなる認証・同意画面も表示してはいけない。エンドユーザが未認証の場合はAuthorization Serverはエラーを返却する
login エンドユーザに再認証を求める(SHOULD)
consent エンドユーザに再同意を求める(SHOULD)
select_account 現在のセッションに複数のエンドユーザアカウントが紐づいている前提で、エンドユーザに利用アカウントの選択を促す(SHOULD)

ちなみにスペース区切りで複数選択が可能(ただしnoneは対象外)です。

リフレッシュトークン発行時等エンドユーザに同意を取りたい、となった際にRP側はpromptパラメータを活用することで同意表示を要求することができるようになっています。
とはいえ、OP側がこの仕様に対応していなければ意味がないので、それぞれ実機側での確認は必要。 個人的には再認証要求時に使うイメージが強かったので、今回読むまでリフレッシュトークンとの関係性に関してはすっかり頭にはなかったのですが、
全体の要件を見ながらシーンごとに設定内容を整理していく必要がありそうです。

ということで

OP・RPの製品固有仕様でこうなっているのかなと勝手に思っていたことが実はOIDCの仕様だったり、改めて読み返してみるとあの課題には仕様側から説明すればよかったのか!と思うことがあったり、
とはいえ色々な事情で仕様通りにはいかないケースもあったりと、奥深さを痛感しつつ、 そう言えばそんな仕様の記載もあったね~と思っていただければ幸いです!

IDトークンの中身を紐解いてみる

OpenID Connectにおける認証情報の送付

これまでの記事で、OAuth2.0やOpenID Connectでいかに安全にユーザの認可や認証を実現するかを少しずつ見ていきました。
これまではどちらかというと、認証リクエストやcodeのやり取りを途中で傍受された場合のケースを考えていましたが、例えばOpenID ConnectのIDトークンを改竄されて送付されるような、認証情報の改竄にはどう対応しているのかというのが気になってきたので、
その前にまずはIDトークンの構造から整理してみようと思います。
IDトークンはJWT(Json Web Token)はJSON形式で表されているので、このJWTの構成も含めてみていきたいと思います。

JWTの形式

JWTは実際のデータとしては例えば下記のような形となります。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QtMDEiLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTUxNjIzOTAyMn0.6pUVriX3UlaNBHs27IfePpeXHz45yWONeLgBxmltIP0

よくわからない文字列に見えますが、じっくり中身を見てみると二つのピリオドで三つのパートに区切られていることがわかります。

  1. eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  2. eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QtMDEiLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTUxNjIzOTAyMn0
  3. 6pUVriX3UlaNBHs27IfePpeXHz45yWONeLgBxmltIP0

それぞれのパートに役割があるので、この文字列を可読なものに変換しながらまとめたいと思います。
先んじてこの役割というのが、順に 1. ヘッダー 2. ペイロード 3. 署名 という構造になっているのですが、 このような構造ででデジタル署名もしくはMAC(Message Authentication Code)化を使用しているデータ構造を「JWS(JSON Web Signature)」と呼びます。
(※署名/MAC化でなく暗号化を使用している場合はJWT=JSON Web Encryption)となります)

1.ヘッダー

JWTはBase64URLエンコードされているので、まずはデコードをします。 上記例に挙げたヘッダー部をデコードするとこんな感じです。

{
    "alg": "HS256",
    "typ": "JWT"
}

ヘッダー部ではJWTの暗号化方式と、追加プロパティについて記述しています。 具体的に使用可能なパラメータはこんな感じ。 RFC7515 に定義があります。

パラメータ名 内容
alg JWSに使われる暗号化アルゴリズムを表示(必須パラメータ)。定義済みのアルゴリズムこちらにあります。
jku jku = JWK(JSON Web Key) Set URLのことで、JWTのデジタル署名に利用される秘密鍵と対応している公開鍵のURLが記載されています。
jwk jkuと同様にJWTのデジタル署名に利用される秘密鍵と対応する公開鍵の情報を表しますが、こちらは鍵本体を記載します。
kid JWSで使用されている鍵のIDを表します。
typ JWSの種類を表します。

先ほどのデコードしたヘッダー部分は、「暗号化アルゴリズムはHS256=HMAC SHA 256で、種類はJWT」ということを示しています。

2.ペイロード

ペイロード部には渡したい情報本体(クレーム)が入っています。
ヘッダーと同様にBase64URLエンコードされているので、デコードすると中身はこんな感じ。

{
    "sub": "1234567890",
    "name": "test-01",
    "iat": 1516239022,
    "exp": 1516239022
}

色々とクレーム名が設定されていますが、実際には仕様で定義済みのものと、発行者が自分で設定できるものがあります。
定義済みのものはこんな感じ(RFC7519)

名前 内容
iss クレームの発行者名
sub 発行されたクレーム保持者の識別子
iat JWTが発行された日時
exp JWTの有効期限
nbf JWTの有効期間開始日時

同様に例で挙げた内容は「クレーム保持者の識別子は"1234567890"で、発行日時は"2018/01/18 10:30:22"、有効期限は"2018/01/18 10:30:22"、nameというクレームが"test-01"」という風になります。(※時刻はUNIX時間で記載)

3.署名

最後のパートが署名です。こちらもBase64URLエンコードされていますが、中身のデータはバイナリとなっています。 ヘッダーのalgパラメータで定義があるアルゴリズムで署名されているので、今回はHMAC SHA256となります。
署名というのは、1・2のヘッダーとペイロード部の内容を対象としているので、今回のようなHS256の場合は秘密鍵を使用してヘッダーとペイロード(base64URLエンコード済のもの)をSHA256でハッシュ化した値が署名された値と一致していれば、クレーム発行者が発行した正しい内容だということがわかります。

署名の形式

上記例ではHS256でしたが、下記のように

{
  "alg": "RS256",
  "kid": "e8732db06287515556213b80acbcfd08cfb302a9",
  "typ": "JWT"
}

algパラメータがRS256、つまり「RSASSA-PKCS-v1_5 using SHA-256」ですが要するに公開鍵暗号を利用しているパターンです。
左記ほどは記載のなかったkidパラメータ=JSWで使用されている鍵のIDが定義されています。
こちらはGoogleが発行したJWSなのですが、Googleが公開している発行情報( https://accounts.google.com/.well-known/openid-configuration )へアクセスすると、

 "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",

という形で公開鍵の情報が記載されているURLが記載されており、さらにアクセスすると、

{
  "keys": [
    {
      "kid": "e8732db06287515556213b80acbcfd08cfb302a9",
      "alg": "RS256",
      "e": "AQAB",
      "n": "4RIrO30287Wsq3gqXCMkUYMVAeI3H8LVE6IXR1krdFeGnZLiGUPwcbkeVpXf3lmJdsStOg-jijces2DZCfPyIBiQuLYfxxmAZE6ErJ0QJFg1stwli2Pz9ncYhFoqi8pXr7kEzEJBTzX4thuw56ydbGsshSEznPXoerCJOc7UI2-n0wFCWQ4YLHbh_PrWt4vdadyUUUW_QpQHXQLdD8q_Qwqdj0O9zlJE7R6Elw2E9EqnHyIGu1hmLxhqrTru1M18SUhONYbVskV_BCEdVKs__X96849HorWQDCAgVMWfGsdMVq55FAdJ680N5UmQDRynIZ4-PeNGN4S9iw2mbMNEBQ",
      "kty": "RSA",
      "use": "sig"
    },
    {
      "e": "AQAB",
      "n": "xT_ngLZNmT5GBdkLtJZjNeTB-8B5yWgrq_e5eMZ1hrZhcmLK-dSnIkpOPV8_OekV67EnQ7I4II2rcNJnHGrGKZziXO3XN2gtUHE-mBJC99oULSbX_QwBKz7gC_IBPq9EuxTt6Oq6fPkVQ9DbRIgWJSEGBF_KRaNl3kyAlIZfpY7XgHyJTTv8E7yAcYKPR-36gzdl-ps0sDLKzUuAtZNq8llK0u80z6AtAUIYwWdkEhM9upy6keKITasIxcsO7M6kZPINUSbh6t5VAm8FuqRmxpgg-9c9_GQSGd89InVypoVzWLQ-wOGg5G4H6JqIgtj0TRFt4gK0eoFi2U0d3l8bcw",
      "alg": "RS256",
      "use": "sig",
      "kty": "RSA",
      "kid": "8462a71da4f6d611fc0fecf0fc4ba9c37d65e6cd"
    }
  ]
}

と、鍵の一覧が取得できます。ヘッダーに記載されているkid「e8732db06287515556213b80acbcfd08cfb302a9」が前者に記載されているので、こちらから公開鍵を取得して署名を検証することとなります。

この辺りの署名の仕組みがまだ疑問が残っている部分も多いので次回はもう少しこの辺りを深堀したいなと思っています。なかなか奥深い分野です。。。

OAuth2.0のPKCEとは

codeの受け渡しをどう守るか?

以前Oauth2.0のstateについて書いていた際にも思いましたが、フローを見ているとアクセストークンの受け渡し、もっと言うとプラスしてcodeの受け渡し部分は非常にセンシティブな箇所です。アクセストークンがあればリソースを取得できますし、codeはアクセストークンの受け渡しの必要条件ですので。

要するに、この簡単なイメージ図で言う「握手券」をどう安全に管理することができるのか?という所なのですが、

f:id:ybnskk:20201102061234p:plain

例えの便宜上逆にややこしい部分もございますが、実際にはstateパラメータを使うことで、①~⑦までの一連の流れが同一人物によって為されているかを確認しているので、

例えば⑤の部分で握手券を奪っても、それを悪意のあるユーザが横入りして使うことはできなかったりします。ちゃんと正規の入り口から入った上で一連の処理が行われているか見ることで、codeの利用を守っているんですね。

しかし実はもう一つ考慮するポイントがあります。これまで出てこなかったClient側でのリスクです。

どういうことかというのを改めて握手会で例えてみると、

f:id:ybnskk:20210202061911p:plain

  1. 参加者は握手券の「予約券(=まだ握手できる権利はない)」を持って会場に行きます。

  2. 参加者が予約券しか持っていないので、警備員(Client)に受付に行くように言われます。

  3. 参加者は受付(Authorization server)に予約券と、本人確認書類を持って行き、「自分が推しのXさんの握手会の予約者本人である」ことを証明します。

  4. 受付は参加者に「Xさんとの握手券」を渡します。

    と、ここまでは問題ないのですが、

  5. 参加者は握手券を持って会場に戻るのですが、会場内に似たような警備員(Client)がいて、知らぬうちに参加者はその怪しい警備員に握手券を渡してしまいます。

  6. 怪しい警備員=悪意あるClientは参加者を放置して自分の物のようにスタッフ(Resource Server)に握手券を渡します。

  7. スタッフは握手券に確認済みの印をつけて警備員に渡します。

というように、ユーザではなくClientの側から握手券=codeを奪い取られる危険性が存在しています(2号館のももクロイベントに行こうと思ったのに間違えて3号館のAKB握手会に行っちゃって、3号館のスタッフに素知らぬ顔でももクロイベントのチケットを奪われてたみたいな感じだなと思いました)。

ですが、この点も基本的には対策がされています。

図は割愛しておりますが、⑥でResource Serverがcodeを受け取る際に、codeといっしょに「client_sercret 」=正しいClientしか使えない文字列を送らせて、その内容が正しいかをチェックすることで、本当に正式なClientか?をResource Server側で確認しています。

つまり怪しい警備員はこのclient_secretの正しい値を持っていないので、codeを手に入れたとしても基本的には利用することはできない仕組みになっています。

しかしWebアプリケーションの場合はこのようにclient_sercretで防ぐことができるのですが、例えばモバイルアプリケーションのように、カスタムURIスキーム(app://~のような)で動作するものの場合は、カスタムURIスキームとアプリがResource Server側で情報登録されていると、悪意あるモバイルアプリケーションがcodeを送信できる可能性が出てきてしまいます(2号館と3号館を同じ場所ととらえてしまったように…)。

PKCEの登場

そこで活躍するのが「PKCE」です。

PKCEは「Proof Key for Code Exchange by OAuth Public Clients」の略で、RFCにも定義があります。

流れとしては下記のような形で、

f:id:ybnskk:20210202061942p:plain

  1. 参加者は握手券の「予約券(=まだ握手できる権利はない)」を持って会場に行きます。

    と、ここまではまた一緒なのですが、次から少し変化します。

  2. 参加者が予約券しか持っていないので、警備員(Client)に受付に行くように言われます。 この際に、「code_verifier」から「code_challenge」を生成し、後者の「code_challenge」とチャレンジを生成する際の方法=「code_challenge_method」を一緒にもっていかせます。

    ※code_challenge_methodは任意のパラメータでデフォルトはplainですが、本来の目的に照らし合わせるとsha256を利用する方が好ましいと考えられます。

  3. 参加者は受付(Authorization server)に予約券と、本人確認書類を持って行き、「自分が推しのXさんの握手会の予約者本人である」ことを証明します。

  4. 受付は参加者に「Xさんとの握手券」を渡します。

    ここで、受付=Authorization serverはcodeを生成する際に、codeと紐づけてcode_challengeとcode_challenge_methodを保持しておきます。

  5. 参加者は握手券を持って会場に戻り、警備員(Client)に渡します。

  6. 警備員はスタッフ(Resource Server)に握手券を渡します。

    ここで、これまでは握手券=codeを渡すのみでしたが、この際に②で生成したcode_verifierを一緒に渡します。これにより、怪しいアプリケーションではなく、②でcodeをリクエストしたアプリケーション本体だということを確認します。

  7. スタッフは受け取ったcode_verifierを、codeに紐づいているcode_challenge_methodで変換します。正しいcode_verifierであれば、同じくcodeに紐づいてたcode_challengeの値=code_verifierとなるはずです。

codeなんちゃらというワードが色々出てきて頭がとっ散らかりますが、

  • code_verifier:正しいClientしか知らない言葉
  • code_challenge:code_verifierを変換して作ったバレてもいい言葉

というイメージで、code_challengeのもとになるcode_verifierを知っているということが、怪しいアプリでないことの確認に重要となります(厳密には違う気がしますが公開鍵暗号みたいだなと思いました…)

フローを見ていてこの部分の安全性はどうやって確かめるんだろう?と疑問に思った点だったので、PKCEの仕様を見て解けました。

OAuth 2.0 認可コードの横取りを実際にやってみる

前回、前々回とOAuth2.0、OpenID Connectのセキュリティに関して簡単にまとめましたが、やっぱり実際にどう実装されているのかをこの目で確かめたくなってきますよね。

ybns.hateblo.jp

ybns.hateblo.jp

ということで、今回はダメダメなアプリ開発者と執念深い攻撃者の二足の草鞋を履いて、stateパラメータがどのように我々を守ってくれているのかを簡単なサンプルコードで試してみました、という実験録となります。

stateのおさらい

stateパラメータはOAuth2.0のフローで認可コードをユーザからクライアントへ送信する際に本当に正しいユーザから送られてきたデータなのかをクライアントが確かめるためのものでした。

前々回

  • 認可コード=握手券
  • ユーザ=握手会の参加者
  • クライアント=握手会会場の管理者

という形で置き換えて説明してみました。

この仕組みがないと、推しとだけの間の秘密の話が、攻撃者の人にも伝わってしまうリスクがあるというお話もしました。

stateパラメータがあるとき

ということで、まずはどんな風にクライアントが認可コードの検証をしているのか?を見ていきたいと思います。

まずはクライアントアプリを用意する必要がありますが、今回はこちらPHPアプリを使わせていただきました。

GitHub - aaronpk/quick-php-authentication: Add Authentication to your PHP App in 5 Minutes!

ご参考までに私の環境は下記のような感じで作成しました。他にも色々やり方はあると思いますがパッとできるやり方でまずは・・・。

  • PHPクライアントアプリ : Azure Web Apps上で構築

  • 認可サーバ:Azure AD(今回はOktaを使おうと思いつつ、結局Azure ADになりました)

①認可サーバへのリダイレクトまで

f:id:ybnskk:20201231070412j:plain

前々回の絵を使いながらですが、この時点でクライアントアプリからはstateパラメータが発行されます。

ですが、それと同時にユーザ側、厳密にはユーザの使うブラウザに対して同様にセッションIDが払い出されています(今回はPHPのデフォルトを使用しているので、PHPSESSIDの値に入っています)。

ブラウザ側ではセッションIDのみですが、クライアント側では、セッションIDとstateが紐づけられて保管されているのがわかります。

②ユーザへの認可コード返却まで

f:id:ybnskk:20201231071115j:plain

その後認可サーバで認証を行い、成功すると認可コードがユーザに返却されます。

実際の値はトレースをとるとこんな感じです。stateの値も入ってきているのがわかります。

f:id:ybnskk:20210101233535j:plain

③クライアントアプリへの認可コード送信まで

f:id:ybnskk:20201231071730j:plain

この認可コードとstateをクライアントアプリへ送信します。その際保持しているセッションIDについても送信します。図の赤丸三点セットです。

ブラウザから送られる赤丸三点セットと、クライアントアプリ側で保持している情報が一致しているかで正式なリクエストかどうかを判別しています。

実際の値も一部確認してみます。ブラウザで保持しているセッションID(PHPSESSID)と

f:id:ybnskk:20210101232228p:plain

クライアントアプリで保持しているstateの値です。

f:id:ybnskk:20210101232249p:plain

ブラウザで保持しているPHPSESSIDの値が、クライアントアプリ側で保持しているセッション保存ファイル名にマッピングされつつ、その中で持っているstateの値(②で見たデータ)と、ブラウザからクライアントアプリへ送信されるstateの値が一致していることがわかります。 この照合ステップで、認可コードが送られた際に正しいユーザからなのか確かめて次のステップへ進んでいくことになります。

stateパラメータがないとき

一方、stateパラメータがない世界では、本当に悪意あるユーザの意図通りに動いてしまうのでしょうか。

先にイメージ図を載せますが、stateの機能している世界だと、悪意あるユーザが認可コードを不正に利用しても、下記のように、ブラウザ側でセッションのないユーザからのリクエストとなり、弾かれるはずです。

f:id:ybnskk:20210101232521j:plain

それでは早速検証のためにちょっと無理やりにstateが機能していないアプリを作ろうと思います。手を加える箇所はそこまで多くないですが、今回使わせていただいたこちらのクライアントアプリのソースから49~51行目のstateの検証個所をコメントアウトします。

 49  /*if($_SESSION['state'] != $_GET['state']) {
 50   die('Authorization server returned an invalid state parameter');
 51 }*/

また、未使用の認可コードを横取りするために、56行目に一行だけコードを付け足します。

53  if(isset($_GET['error'])) {
54    die('Authorization server returned an error: '.htmlspecialchars($_GET['error']));
55  }
56  sleep(200);

sleepの時間は適当ですが、一度ここで処理を止めないとクライアントアプリが認可コードを使ってtoken_endpointへリクエストを投げてしまい、横取りした認可コードが使えなくなってしまうので、リクエスト前にいったん処理を止めます(sleepしている間にすることが多いので、長めに設定していますが後程コメントアウトします)。

この状態で下記の手順で動かしてみます。

①ブラウザを立ち上げて、F12で開発ツールを表示 ※ネットワークトレースが取れるなら他ツールでも可です

②クライアントアプリへアクセス

③認可サーバへリダイレクトされる

④認可サーバでID/パスワードを入力 ←いったんここまで!

認証完了後、56行目に付け足したsleepコマンドの部分で処理が止まっているので、ブラウザを中断しつつ、立ち上げている開発ツールのネットワークログから、認可コードが返ってきている箇所を探します。

ここで表示されているURLに認可コードやstateが含まれているので、がさっとコピーした後、別のブラウザへ貼り付けます。

そしてコピーしたURLを実行!する前に、時間短縮のため先ほど設定したsleep設定をコメントアウトしておきます。

53  if(isset($_GET['error'])) {
54    die('Authorization server returned an error: '.htmlspecialchars($_GET['error']));
55  }
56  //sleep(200);

では改めて、ようやくURL実行!

f:id:ybnskk:20210101232729p:plain

アクセスできてしまいました。stateの検証をしていないので当たり前なのですが、未使用の認可コードを取得できれば意図しないユーザがログインできてしまうという恐怖の片鱗が少し掴めるかなと思います。

ここでstateを検証しているとしっかり不正なアクセスということでログインを弾くことができます。

こうして見ると、認可サーバー側も認可コードの再利用ができないよう対策はしていますが、使い捨てられていない認可コードさえ使われてしまうと、認可サーバ側では対処しようがなく

認可コードとユーザの紐づきの部分はクライアント側で責任をもって実装しなければならない箇所だということが改めてわかるかなと思います。

Open ID Connectのnonceパラメータの意義についても整理してみる

前回OAuth2.0でのstateパラメータの役割について簡単にまとめましたが、OAuth2.0ではなくOpen ID Connectではstateパラメータに加えてnonceも利用されます。この二つのパラメータの違いが最初は理解しづらい部分もあったので、今回はこちらのnonceパラメータの役割についてもかいつまんで記載したいと思います。

Open ID Connectの流れ

OAuth2.0は「認可」を目的としたフレームワークということで、ユースケースで理解するのに苦労したのですが、Open ID Connectは認証に主眼が置かれている仕組みということもあり、もう少しオンライン寄りな例も可能かなと思います。

例えば昨今ニーズの増えているオンラインライブにLINEアカウント(外部IdP)のIDでログインしたいようなケースを考えてみます。

f:id:ybnskk:20201202044533p:plain

  1. 参加者はオンラインライブのサイトへアクセスします。
  2. 参加者がまだ認証されていないので、認証サーバ=LINEの認証エンドポイントへリダイレクトされます。
  3. 参加者はLINEの認証画面で認証を実施します。
  4. LINEは参加者にコードを返却します。
  5. 返却されたコードとともにオンラインライブのサイトへリダイレクトされます。
  6. オンラインライブのサイトはコードをLINEへ送信します。
  7. LINEはそのコードを元にIDトークンをオンラインライブのサイトへ返却します。

ここまで終了すると、オンラインライブのサイトに対して参加者が誰か、という情報が伝わり、参加者マイページ等の表示が可能となります。

セキュリティリスク:IDトークンの横取り

しかし、こちらの図を見ていると、もし⑦のIDトークンを返却する際にこのトークンを通信を傍受されるなどをして横取りされた場合、正規の参加者のふりをして悪意あるユーザがログインできてしまうのではないか?という懸念があります。

f:id:ybnskk:20201202044600p:plain

ここで登場するのがnonceパラメータとなります。nonceパラメータは、②の認証サーバへリダイレクトされる際に発行され、最後⑦のIDトークンの中にもnonceパラメータの値が含まれています。

悪意あるユーザの横取りを防ぐ仕組みは下記のイメージです。

f:id:ybnskk:20201202044614p:plain

  • 参加者Aさんが認証サーバへリダイレクトされる際、オンラインライブのサイトはAさんのセッションに紐づくnonceを発行します(①~②)。
  • Aさんは外部IdPで認証を実施し、IDトークンが発行されます(③~⑦)。
  • ここでIDトークンを悪意あるユーザが横取りします。が、同時にオンラインライブのサイトは認証完了時点でAさんセッションに紐づくnonceを破棄します。
  • 悪意あるユーザがIDトークンをオンラインライブのサイトへ送信しますが、IDトークンに含まれるnonceはすでに破棄されているので一致が確認できず、認証が失敗します。

このようにnonceパラメータを正しく発行・検証することで安全に外部IdPを用いた認証が実現できます。

stateとの違い

前回書いたstateと今回のnonceはセキュリティに関するパラメータだとは理解しつつ、違いを理解するのが難しかったりしますが(nonceはOIDCで出てくる、くらいの認識だったり、CSRF攻撃を防ぐという目的は一緒だったり)

それぞれ役割だったり検証方法が違うものであるということがCSRF攻撃を防ぐという観点だけでもわかるかなと思います。

項目 state nonce
防ぎたいこと ユーザが攻撃者として認識されること(攻撃者のコードを使わされること) 攻撃者がユーザとして認識されること(IDトークンを横取りされること)
攻撃ポイント アクセストークン発行前 IDトークン発行後
方法 クライアントが保存しているデータとパラメータの一致を検証 クライアントが保存しているデータとIDトークン内のパラメータの一致を検証

OAuth2.0、Open ID Connectのセキュリティという面ではさらに実装ポイントもありますが、よく使う機会のあるstate、nonceに関して簡単にですがまとめてみました。

OAuthのstateパラメータの意義をもう少しかみ砕いて理解する

先日OAuthのセキュリティについて勉強する機会があり、RFCを読んでみたり外部の解説サイトを拝見したりしていました。ただ、文字では何となく理解できるものの、自分で認可サーバを作った経験もないので、今一つ腹落ちせずな状況で、システムからやや離れた事例で考えると理解しやすいかな?とも思い、少し整理したいと思います。

OAuthの流れ

身近な例、ということで握手会に行くときのケースで考えてみました。ここではオンライン上のやり取りではなく、F2Fですが実際それなりに煩雑な手続きが必要で、割と近いかなとも思います(若干認証の流れの方に合わせている部分もあります。)

  • 握手会の参加者:Resource Owner
  • 会場運営/警備員:Client
  • アイドル事務所:Resource Server/Authorization Server

という登場人物です。会場運営(Client)はあくまで場の管理をする立場で、リソース(握手イベント)自体はアイドル事務所(Resource Server)が提供しているイメージです。

f:id:ybnskk:20201102061234p:plain

  1. 参加者は握手券の「予約券(=まだ握手できる権利はない)」を持って会場に行きます。
  2. 参加者が予約券しか持っていないので、警備員(Client)に受付に行くように言われます。
  3. 参加者は受付(Authorization server)に予約券と、本人確認書類を持って行き、「自分が推しのXさんの握手会の予約者本人である」ことを証明します。
  4. 受付は参加者に「Xさんとの握手券」を渡します。
  5. 参加者は握手券を持って会場に戻り、警備員(Client)に渡します。
  6. 警備員はスタッフ(Resource Server)に握手券を渡します。
  7. スタッフは握手券に確認済みの印をつけて警備員に渡します。

最終的に、警備員(Client)が確認済みの握手券(アクセストークン)を持って、参加者が握手したいアイドルの握手スペースをオープンし、晴れて握手ができる、という流れです。

セキュリティリスク

では、ここで考えられるセキュリティリスクとはどんなものでしょうか。今回は下記のリスクと対策についてまとめていきたいと思います。

Cross-Site Request Forgery(CSRF

CSRFはユーザに意図しないリクエストをサーバに送信させる攻撃です。OAuthの認証フローにおいては下記のような流れが想定されます。

f:id:ybnskk:20201102061403p:plain

  • 悪意あるユーザは受付で自分の握手券を入手します(①~④)。
  • 何らかの方法で標的のユーザと自分の握手券を入れ替えます。
  • 握手券は「悪意あるユーザが持っている握手券」として認証されます(⑤~⑦)。

一見標的のユーザも握手ができるので問題はなさそうですが、例えばユーザは推しのXさんにだけ自分の秘密を話したとします。対面の握手会では顔が見えているのでやや想像しにくいですが、ユーザは握手券の情報のみから判別すると「悪意あるユーザ」となります。次回、悪意あるユーザがXさんに会いに行くと、Xさんからすると同じ悪意あるユーザとして認識されるので、もしかすると前回聞いた秘密を悪意あるユーザに話してしまうかもしれません。

このようなケースもユーザにとってはセキュリティリスクであり、握手券を発行した相手と握手券を持ってきた人が同一人物か確認する必要があります。

stateパラメータの利用

その対策として実装されているのが「state」パラメータです。

f:id:ybnskk:20201102061455p:plain

  • ユーザB(悪意あるユーザ)が受付で認証をする際、受付はBさんの情報といっしょにBさんのstateパラメータを保存します(①~④)。
  • ユーザBがユーザA(標的のユーザ)に自分の握手券を何らかの方法で渡しアクセスさせます。
  • しかし、利用しているstateパラメータはユーザBのものであるため、ユーザAは利用できずエラーとなります。

stateパラメータによって、トークンエンドポイントにおいて、codeを受け取った人とトークンをリクエストする人が一致しているかを確認することができます。