Azure AD v2.0 endpoint の OAuth Token の検証 (Verify)


※ v2 endpoint に関する内容です。(Azure AD (v1) の場合は、こちら を参照してください。)

Azure AD v2.0 endpoint を使った Azure AD & MSA 対応アプリ開発

こんにちは。

v2.0 endpoint の OAuth を使った Client 開発」では token を取得して処理しましたが、そもそも、この token が正しいものであることをどのように保証すれば良いでしょうか ? (この token は信じて安全でしょうか ?) また、ログインしたユーザーの所在や基本情報をどのように識別すれば良いでしょうか ?

今回は、Azure Active Directory (Azure AD) と Microsoft Account (MSA) の双方に対応した v2.0 endpoint を使って、token の中身を確認して検証 (Validate, Verify) する方法や考え方をプロトコル レベルで解説します。(一般の OAuth token の基礎知識や考え方を、v2 endpoint のケースで解説します。)

v2.0 endpoint における Token

v2.0 endpoint で返される token のフォーマットをまとめると、下記の通りです。

id token access token
組織アカウント (Azure AD) JWT JWT
個人アカウント (MSA) JWT 独自の Compact Tickets

 

上記の JWT (Json Web Token) については、このあと詳細を解説します。

一方、「独自の Compact Tickets」は、Outlook.com (旧 Hotmail)、OneDrive (旧 SkyDrive) などの Microsoft のサービス (旧 Live Services) のみが解釈可能な独自の Ticket で、皆さんが開発するプログラムでこの Compact Tickets を扱った処理 (検証など) は不可能です。
このことは、つぎの通り、開発方法に大きな影響を与えます。

まず、Azure AD v1 endpoint では、access token のみを取得して、その結果を検証するという実装 (プログラミング) が可能でした。しかし、v2 endpoint で Microsoft Account (MSA) も対象とする場合は、id token と code (id_token+code)、id token と access token (id_token+token) など、必ず id_token も同時に取得し、後述する Claim の取得や検証 (Verify) をおこなう必要があります。(access token は、API Service に接続するための文字通り「access のための token」としてのみ使い、Client の検証には id token を使うという、本来のプログラミングが必須です。)
このあと、この検証方法を解説します。

補足 : Azure AD v1 endpoint における access token の検証手順については「Azure AD を使った Service (API) 開発 (access token の verify)」を参照してください。

また、一般の開発者が、Microsoft Account (MSA) を対象に、Client から呼び出される API Service (Web API) のアプリケーション (OneDrive, Outlook.com などと同列のカスタム アプリ) を自作しようと思っても、現時点では不可能です。上述の通り、Client から渡される access token を処理 (検証) できないためです。
Microsoft Account (MSA) を使った個人アカウントの場合は、対象のサービスは Outlook.com、Microsoft Graph などの Microsoft が提供するサービスのみと考えてください。(現時点では、Microsoft Graph も含む Outlook.com に対する処理のみが v2 endpoint でサポートされています。)

補足 : ただし、OWIN などを使った Companion どうしのプロトコル (独自の token など) を扱うことで、単一アプリ内で UI と API に分離して連携させることは可能です。
このサンプルは、「Azure Active Directory : Secure an MVC web API」で紹介されているので参照してください。

OAuth JSON Web Token (JWT)

上述した JWT (Json Web Token) は、IETF (Internet Engineering Task Force) で定めされたフォーマットであり、下記フォーマットです。

  • JWT (id token など) は dot (.) 区切りの 3 つのトークン文字列で構成されています
  • 各トークン文字列は、RFC 4648 による変形 Base 64 により Encode されています
  • 各トークンを Decode すると、下記の内容が記載されています
    • 1 番目には、Key の種類、Key の ID (X.509 Thumprint) など証明書 (Key) に関する基本情報が含まれています
      (これは、証明書が入れ替わるタイミングなどを除き、概ね 固定の値です)
    • 2 番目には、Claim 情報が入っています。後述しますが、表示名 (氏名) やメールアドレスなどの基本情報と、有効期限や付与された scope (access token の場合) などセキュリティ情報を含んでいます
    • 3 番目には、デジタル署名 (digital signature) の byte code が入っています。

補足 : IETF の OAuth JWT の仕様詳細については、下記を参照してください。
http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html

補足 : ここでは説明しませんが、「v2.0 endpoint の OAuth を使った Client 開発 (Azure AD と MSA への対応)」で述べた (client secret の代わりに) 証明書を使う方法 ([Generate New Key Pair] をクリックして作成される証明書を使う方法) も、上記と同じ JWT フォーマットの client assertion を使用します。(この場合は、以降で記載する方法とは逆に、private key を Application 側で保持し、public key による検証を Microsoft 側でおこないます。)

上述の「RFC 4648 による変形 Base 64 Encode」は、URL 文字列 (約束語) である「+」を「-」、「/」を「_」、末尾の「=」を削除した Base64 エンコードですので、簡易なプログラムを作成して変換できます。かなり昔の投稿になりますが、.NET (C#) のプログラミング サンプルは「OAuth 2 Token (JWT) の Decode」、PHP のプログラミング サンプルは「Azure AD を使った Service (API) 開発 (access token の verify)」(または後述) を参照してください。

Claim の取得

OAuth でログインしたユーザーの基本情報を取得するには、わざわざ Azure AD Graph などの API を発行する必要はなく、上述の通り、id token の JWT から Claim (上述の 2 番目のトークン) を参照すれば OK です。

例えば、下記は、id token から Claim の一覧を取得して表示 (dump) するサンプルです。
「Login !」と書かれたボタンを押すと認証がおこなわれ (ログイン画面を表示し)、ログインに成功すると取得した Claim の一覧を表示します。

<?php
  if(isset($_POST['id_token'])) {
    $token_arr = explode('.', $_POST['id_token']);
    $claims_enc = $token_arr[1];
    $claims_arr = json_decode(base64_url_decode($claims_enc), TRUE);
  }

  // Helper functions
  function base64_url_decode($arg) {
    $res = $arg;
    $res = str_replace('-', '+', $res);
    $res = str_replace('_', '/', $res);
    switch (strlen($res) % 4) {
      case 0:
        break;
      case 2:
        $res .= "==";
        break;
      case 3:
        $res .= "=";
        break;
      default:
        break;
    }
    $res = base64_decode($res);
    return $res;
  }  
?>
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Test page</title>
</head>
<body>
  <button id="btnLog">Login !</button>
  <pre>
  <?php var_dump($claims_arr); ?>
  </pre>
  <script>
    (function () {
      document.getElementById('btnLog').onclick = function() {
        location.href = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?response_type=id_token+code&response_mode=form_post&client_id=7822587c-fed4-4dd3-8e68-165334eb7c92&scope=openid+https%3a%2f%2fgraph.microsoft.com%2fmail.read&redirect_uri=https%3A%2F%2Flocalhost%2Ftest.php&nonce=abcdef';
      };
    }());   
  </script>
</body>
</html>

実行結果は下図の通りです。(下図は Microsoft Account で認証した場合の結果の例です。)

つまり、認証フローによって返される id_token を Decode した中身は、下記のような Json フォーマットになっています。

{
  "ver": "2.0",
  "iss": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0",
  "aud": "7822587c-fed4-4dd3-8e68-165334eb7c92",
  "exp": 1457414781,
  "iat": 1457328381,
  "c_hash": "CF8iD7zXmjcGoJe_9ru9Hg",
  "nonce": "abcdef",
  "sub": "AAAAAAAAAAAAAAAAAAAAAI4mp8lGZNTP1pnIVwMXM70",
  "tid": "9188040d-6c67-4c5b-b112-36a304b66dad"
}

Application では、ここで指定された token の有効期限 (上記の exp) や client id (上記の aud) を確認して、もし問題あればエラーの表示などをおこないます。Azure AD を使用しているケースで、もし利用テナントを制限したい場合は、上記の tid (tenant id) なども確認します。他に、上記の sub を使って、利用者の同一性なども確認できます。(なお、この sub は、同一ユーザーであっても Application ごとに異なる pairwise id ですので注意してください。)

補足 : 上記の nonce については「JavaScript による Azure AD 連携 (OAuth Implicit Grant)」を参照してください。(ここでは説明しません。)

上記結果には、ユーザーの表示名 (氏名) やメールアドレスなどの基本情報は含んでいませんが、もしこれらの基本情報を取得したい場合は、下記太字の通り scopeprofile を追加して Basic Profile も取得します。(メールアドレスを取得する場合は、この他に emailscope も追加してください。)

<?php
  if(isset($_POST['id_token'])) {
    $token_arr = explode('.', $_POST['id_token']);
    $claims_enc = $token_arr[1];
    $claims_arr = json_decode(base64_url_decode($claims_enc), TRUE);
  }

  // Helper functions
  function base64_url_decode($arg) {
    $res = $arg;
    $res = str_replace('-', '+', $res);
    $res = str_replace('_', '/', $res);
    switch (strlen($res) % 4) {
      case 0:
        break;
      case 2:
        $res .= "==";
        break;
      case 3:
        $res .= "=";
        break;
      default:
        break;
    }
    $res = base64_decode($res);
    return $res;
  }  
?>
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Test page</title>
</head>
<body>
  <button id="btnLog">Login !</button>
  <pre>
  <?php var_dump($claims_arr); ?>
  </pre>
  <script>
    (function () {
      document.getElementById('btnLog').onclick = function() {
        location.href = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?response_type=id_token+code&response_mode=form_post&client_id=7822587c-fed4-4dd3-8e68-165334eb7c92&scope=openid+profile+https%3a%2f%2fgraph.microsoft.com%2fmail.read&redirect_uri=https%3A%2F%2Flocalhost%2Ftest.php&nonce=abcdef';
      };
    }());   
  </script>
</body>
</html>

取得される Claim は下記の通りです。(太字部分が追加で取得されます。)

{
  "ver": "2.0",
  "iss": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0",
  "aud": "7822587c-fed4-4dd3-8e68-165334eb7c92",
  "exp": 1457416091,
  "iat": 1457329691,
  "c_hash": "SJIA5xc5f4ryvCRYP2Lkew",
  "nonce": "abcdef",
  "name": "剛 松崎",
  "preferred_username": "tsuyoshi.matsuzaki@hotmail.com",
  "sub": "AAAAAAAAAAAAAAAAAAAAAI4mp8lGZNTP1pnIVwMXM70",
  "tid": "9188040d-6c67-4c5b-b112-36a304b66dad"
}

なお、上記の通り、response_typeid_token を付与している場合、返ってくる情報の取り扱いに注意してください。
v2.0 endpoint の OAuth を使った Client 開発」で示したサンプルでは code しか取得しないため、上述のようなセキュリティ情報や個人情報などを含んでおらず、暗号化の対象に含めずに URI の query 文字列として受け取ることが可能でした。(code が仮に盗まれても、client secret などの情報がない限りこれらの個人情報は取得できませんでした。) しかし、response_typeid_token を付与している場合は、返される情報そのものにセキュアな情報を含んでいるため、上述の通り、必ず response_mode=form_post を使って Form の POST Body として返すようにします。(もし、token が URI の query 文字列として含まれていると、ブラウザーがこの URI を処理する際、token が無意味にネットワーク上を流れたり、アクセス URI としてサーバー上のログなどの形で token が残ることになります。これは、セキュリティや情報管理の観点で好ましくありません。)

Token の検証 (Verify)

さて、ここまでは、「取得した token が正しい」という前提で記載しましたが、この前提が成立しない場合はどうでしょう。
例えば、多少のプログラミングの知識があれば、偽のインターネット プロキシを PC 上で立ち上げて HTTP Response (上述の Claim) を改竄することで、別のユーザーになりすまし、Application を通して、本来アクセスできない他人の重要な情報へアクセスできるかもしれません。(実際、Fiddler というプロトコル キャプチャー ソフトはこのような方法で動作しており、FiddlerScript というスクリプトを使って HTTP Response も簡単に変更できます。https を使っていても可能です。)
つまり、Application は、通常、その情報 (id token など) が改竄されていない「正しい情報である」ことを前提としていますが、このままでは、その前提自体が成り立ちません。Application には重大なセキュリティ ホール (抜け道) があります。

OAuth の JWT では、上述の 3 番目のトークンであるデジタル署名 (digital signature) を使って、こうした点を解決します。

上述の digital signature (byte code) は、Microsoft のアイデンティティ基盤 (Azure AD や MSA) だけが保持している private key を使って、上述の {token1}.{token2} の文字列を元に生成されています。そして、公開されている public key を使うと、この署名の正しさの確認 (検証) が可能です。(この public key は、署名の検証のみが可能で、署名作成は不可能な、一方向の非対称キーです。)
仮に Claim が不正に変更 (改竄) された場合、{token1}.{token2} の文字列 (バイト列) は変わってしまうため、再度、新しい署名 (digital signature) が必要ですが、前述の通り、この署名を作成できるのは private key を持っているアイデンティティ基盤 (Azure AD や MSA) だけです。(悪意のあるプログラムが正しい署名を添付することは不可能です。)
つまり、Application で public key を使って、添付された digital signature の検証 (Validation, Verify) をおこなうことで、Claim が改竄されていないことが保証され、 Application を守ることができます。Application 開発 (プログラミング) の際には、こうした検証の処理を怠らずに、確実に実装するようにしてください。

この検証 (確認) の具体的手順は、下記の通りです。

まず、Application では、署名の確認のために public key を取得します。
public key の情報は、上記の Issuer Url (上記の iss の値) に /.well-known/openid-configuration を付与した下記 URL で取得できます。(下記の 9188040d-6c67-4c5b-b112-36a304b66dad の箇所は、利用環境によって変わります。)

GET https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0/.well-known/openid-configuration

上記の結果として下記の Response Body が返りますが、この中の jwks_uri が key の情報を含んだ Uri です。

{
  "authorization_endpoint": "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize",
  "token_endpoint": "https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
  "token_endpoint_auth_methods_supported": [
    "client_secret_post",
    "private_key_jwt"
  ],
  "jwks_uri": "https://login.microsoftonline.com/consumers/discovery/v2.0/keys",
  "response_modes_supported": [
    "query",
    "fragment",
    "form_post"
  ],
  "subject_types_supported": [
    "pairwise"
  ],
  "id_token_signing_alg_values_supported": [
    "RS256"
  ],
  "http_logout_supported": true,
  "response_types_supported": [
    "code",
    "id_token",
    "code id_token",
    "id_token token"
  ],
  "scopes_supported": [
    "openid",
    "profile",
    "email",
    "offline_access"
  ],
  . . .

}

jwks_uri (https://login.microsoftonline.com/consumers/discovery/v2.0/keys) にアクセスすると複数の key が返されるので、これら key のうち、Key ID (kid) が、上述の 1 番目のトークンで返される値 (kid の値) と同一のものが、必要となる public key を含む key です。(すみません、上記の token の中身を表した図はセミナーで使っているものを流用しているので v1 のときの資料になっていますが、v2 endpoint では上図の x5t の代わりに kid が設定されています。。。)

例えば 下記は、PHP を使って、この確認をプログラミングしたサンプルです。下記の openssl_verify 関数では、上記の {Token1}.{Token2} を入力値として証明書の確認 (検証) をおこなっており、証明書に問題ない場合は 1 が返ります。(ここでは省略していますが、現実の開発では、証明書そのものが偽装されるケースに対応し、証明書の chain や issuer などの確認をおこなうと良いでしょう。)
基本的な流れは、Azure AD v1 endpoint のときに解説した「Azure AD を使った Service (API) 開発 (access token の verify)」と同じです ! (参照している属性名などは一部異なります。。。)

<?php
  $token_valid = 0; // 0:Invalid, 1:Valid
  
  if(isset($_POST['id_token'])) {
    // 1 create array from token separated by dot (.)
    $token_arr = explode('.', $_POST['id_token']);
    $headers_enc = $token_arr[0];
    $claims_enc = $token_arr[1];
    $sig_enc = $token_arr[2];
 
    // 2 base 64 url decoding
    $headers_arr = json_decode(base64_url_decode($headers_enc), TRUE);
    $claims_arr = json_decode(base64_url_decode($claims_enc), TRUE);
    $sig = base64_url_decode($sig_enc);

    // 3 get key list
    $keylist = file_get_contents('https://login.microsoftonline.com/consumers/discovery/v2.0/keys');
    $keylist_arr = json_decode($keylist, TRUE);
    foreach($keylist_arr['keys'] as $key => $value) {
      
      // 4 select one key
      if($value['kid'] == $headers_arr['kid']) {
        
        // 5 get public key from key info
        $cert_txt = '-----BEGIN CERTIFICATE-----' . "\n" . chunk_split($value['x5c'][0], 64) . '-----END CERTIFICATE-----';
        $cert_obj = openssl_x509_read($cert_txt);
        $pkey_obj = openssl_pkey_get_public($cert_obj);
        $pkey_arr = openssl_pkey_get_details($pkey_obj);
        $pkey_txt = $pkey_arr['key'];
        
        // 6 validate signature
        $token_valid = openssl_verify($headers_enc . '.' . $claims_enc, $sig, $pkey_txt, OPENSSL_ALGO_SHA256);
      }
    }
 
  }

  $result_txt = 'Token is Invalid (or not authenticated) ...';
  if($token_valid == 1)
    $result_txt = 'Token is Valid !';

  // Helper functions
  function base64_url_decode($arg) {
    $res = $arg;
    $res = str_replace('-', '+', $res);
    $res = str_replace('_', '/', $res);
    switch (strlen($res) % 4) {
      case 0:
        break;
      case 2:
        $res .= "==";
        break;
      case 3:
        $res .= "=";
        break;
      default:
        break;
    }
    $res = base64_decode($res);
    return $res;
  }  
?>
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Test page</title>
</head>
<body>
  <button id="btnLog">Login !</button>
  <pre>
  <?php echo($result_txt); ?>
  </pre>
  <script>
    (function () {
      document.getElementById('btnLog').onclick = function() {
        location.href = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?response_type=id_token+code&response_mode=form_post&client_id=7822587c-fed4-4dd3-8e68-165334eb7c92&scope=openid+https%3a%2f%2fgraph.microsoft.com%2fmail.read&redirect_uri=https%3A%2F%2Flocalhost%2Ftest.php&nonce=abcdef';
      };
    }());   
  </script>
</body>
</html>

ログイン後 (成功の際) の出力結果

補足 : 本投稿ではライブラリー (SDK) の説明はしませんが、Microsoft Authentication Library (MSAL) や OWIN などのライブラリーでは、こうした検証 (確認) 処理を内部でおこなっています。
ライブラリーや SDK を使う大きなメリットの 1 つはコレです。

 

Comments (0)

Skip to main content