OpenID ConnectのIDトークンの内容と検証

(2017-10-09)

OpenID Connectは認可(AuthoriZation)のプロトコルであるOAuth 2.0を正しく認証(AutheNtication)に使うためのプロトコル。

OpenID Connect Core 1.0(日本語訳)

OAuth2.0のメモ - sambaiz-net

OpenID ConnectではOAuthのアクセストークンに加えて Issuer(IdP)によって署名されたJWT(JSON Web Token)形式のIDトークンも返す。 このIDトークンの署名を検証し、含まれるIssuerとクライアントの情報を参照することで OAuthのImplicit flowでのトークン置き換え攻撃を防ぐことができる。

JWT/IDトークン

JWTはRFC7519で定義されている、 パーティ間で安全にClaim(エンドユーザーのようなエンティティの情報)を受け渡すための表現方法。 JSONにエンコードしたClaimは、JOSE(Javascript Object Signing and Encryption)のサブセットであるJWS(JSON Web Signature)のペイロードとして署名を付与されるか、JWE(JSON Web Encryption)で暗号化される。 以下のJWTはJWSのもの。

JWSには(ヘッダ).(ペイロード).(署名)の文字列で表現されるCompact SerializationとJSONで表現されるJSON Serializationがあるが、JWTではCompact Serializationを使う。

ヘッダには署名に使うアルゴリズムalgが含まれる。 JWTを受け取った際、不正なalgになっていないかチェックする必要がある。

{
  "alg": "RS256",
  "kid": "5b0924f6f83c719514987954cf66683b370677d4"
}

ペイロードには以下のようなClaimが含まれる。これ以外のClaimを含めることもできる。

{
    "iss": "https://server.example.com", # IssuerのIdentifier。httpsのURL
    "sub": "24400320", # Subject Identifier。Issuerでユニークなエンドユーザーの識別子。
    "aud": "s6BhdRkqt3", # audience。OAuth2.0のclient_id
    "nonce": "n-0S6_WzA2Mj", # リクエストで送ったのがそのまま返ってくる。リプレイ攻撃を防ぐため
    "exp": 1311281970, # IDトークンの有効期限。時間はすべてUNIXエポック秒
    "iat": 1311280970, # IDトークンの発行時刻
    "auth_time": 1311280969 # エンドユーザーの認証時刻
}

IDトークンを取得する

GoogleのOAuth 2.0 APIはOpenID Connectに対応している。これのIDトークンを取得する。

エンドポイント等はOpenID Connect Discovery 1.0/.well-known/openid-configurationで取得できるようになっている。

$ curl https://accounts.google.com/.well-known/openid-configuration | jq
{
  "issuer": "https://accounts.google.com",
  "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
  "token_endpoint": "https://www.googleapis.com/oauth2/v4/token",
  "userinfo_endpoint": "https://www.googleapis.com/oauth2/v3/userinfo",
  "revocation_endpoint": "https://accounts.google.com/o/oauth2/revoke",
  "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
  "response_types_supported": [
    "code",
    "token",
    "id_token",
    "code token",
    "code id_token",
    "token id_token",
    "code token id_token",
    "none"
  ],
  "subject_types_supported": [
    "public"
  ],
  "id_token_signing_alg_values_supported": [
    "RS256"
  ],
  "scopes_supported": [
    "openid",
    "email",
    "profile"
  ],
  "token_endpoint_auth_methods_supported": [
    "client_secret_post",
    "client_secret_basic"
  ],
  "claims_supported": [
    "aud",
    "email",
    "email_verified",
    "exp",
    "family_name",
    "given_name",
    "iat",
    "iss",
    "locale",
    "name",
    "picture",
    "sub"
  ],
  "code_challenge_methods_supported": [
    "plain",
    "S256"
  ]
}

テスト用にcodeを受け取ってトークンをリクエストするサーバーを書いた。コードはここ。client_idとclient_secretはAPI Consoleで発行できる。

立ち上げてhttps://localhost:3000/authにアクセスするとリダイレクトし、以下のような情報が出力される。

{
  "code": {
    "state": "*****",
    "code": "*****",
    "authuser": "0",
    "session_state": "*****",
    "prompt": "none"
  },
  "token": {
    "access_token": "*****.*****.*****"
  },
  "id_token_header": {
    "alg": "RS256",
    "kid": "5b0924f6f83c719514987954cf66683b370677d4"
  },
  "id_token_payload": {
    "azp": "*****",
    "aud": "*****",
    "sub": "*****",
    "email": "****@gmail.com",
    "email_verified": true,
    "at_hash": "*****",
    "nonce": "*****",
    "iss": "https://accounts.google.com",
    "iat": 1506613038,
    "exp": 1506616638
  },
  "id_token_verify_signature": "*****"
}

このIDトークンのiss, audを見て署名も検証することで、 たしかに発行元と先が正しいことを確認し、expも過ぎていなければ、 subに示されるIDのエンティティとして認証できる。

IDトークンの署名を検証する

検証もやってみた。urlはhttps://localhost:3000/verify?token=****

GoogleのIDトークンのalgを見ると、RS256(RSASSA-PKCS1-v1_5 using SHA-256)で署名されていることがわかる。対象となるデータはJWSの(ヘッダ).(ペイロード)まで。

RSA暗号とPEM/DERの構造 - sambaiz-net

公開鍵はDiscoveryのjwks_uriで取得でき、1日に1回更新される。

$ curl https://www.googleapis.com/oauth2/v3/certs
{
 "keys": [
  {
   "kty": "RSA",
   "alg": "RS256",
   "use": "sig",
   "kid": "23e255c65b234549cc0fe3073bce15e59bd4d4b0",
   "n": "w5i-jGiwEyuPewnvR-lFceBRYh4gx91-OFLaJwwr8yCrSVczAgyc1wywFBCsUBDBhHpKSVilqIGG2fIqhdX2_IFJ-OxYvXDmJtYF69kWTafZjFtnAl8EdIqj1X-y31Pm9gYD_rYeLG3CZhNLjIE_y9fk5_MbOOc0Z-br4_wzing6HfERITbAOAfCd8Ri0_tXDqYgi-C1C_gs2HheYEIWqpZ2se8UsGvIg2uePOCV8G3a0fuvh6hgjutspfJ_VH3eeHwYwyYzieq-sDWcyV5qGlnJp9TZlZ9z242WdYHj3C2kudNTUg76p6svbs6cu1ZiZA9WZkaL9d8hWeJ4tLQg3Q",
   "e": "AQAB"
  },
  {
   "kty": "RSA",
   "alg": "RS256",
   "use": "sig",
   "kid": "db15c5e7c1b82b93388459602e4852bfd9b95931",
   "n": "lZUcUSL9piIsbwP_Y84683P7-vX_Y9CEvqpeCNpI4p55HFCDnp9xtnvc5mBEOrFP-vwk6sjlkLVbl74d1CR-jKX-z8zPg3T0qQzYWgedAddfQL1zFUyo2BLbCg2JeYDZF6IHv6qfwzM3hgQIMJMa29izyAyZ2T0zhXf5fU311LEKWCdpemQsNj5V4r5Z52vsTuOhm16Xt7LWx_iWb-_VdYxhDYoQ87pZIVaCdnKDwGON0MPoI4eQJdb-ABrcz290mbGJ8kiI4BU_iA98HCc3ifWDe8eatpV9LK54eYansDTMQJXoYZ6a7C-0-Mh1-g6qaxYjpymJXbJjYitiMejYFQ",
   "e": "AQAB"
  },
  ...
 ]
}

signature,n,eをそれぞれbase64デコードしたのを整数として扱い、m ≡ (signature)^e (mod n)で複合するとdigestInfoのDERの前に00 01 ff ff .. 00のパディングがついたものになる。

digestInfo ::= SEQUENCE {
     digestAlgorithm DigestAlgorithmIdentifier,
     digest Digest 
}

Digest ::= OCTET STRING
const digestInfoDERFromSignature = (signature, e, n) => {
  const signatureHex = Buffer.from(signature, 'base64').toString('hex')
  const eHex  = Buffer.from(e, 'base64').toString('hex')
  const nHex = Buffer.from(n, 'base64').toString('hex')

  const signatureNum = bigInt(signatureHex, 16)
  const eNum = bigInt(eHex, 16)
  const nNum = bigInt(nHex, 16)  

  const m = signatureNum.modPow(eNum, nNum); // c^e (mod n)
  const decrypted = m.toString(16);
  const paddingRemoved = decrypted.replace(/^1f*00/g, "");
  return paddingRemoved;
}

この中のdigestと(ヘッダ).(ペイロード)のsha256 hashが一致することを確認する。 digestは末尾にくるので簡易的にendsWithで比較している。