Erlang
OAuth
Elixir
openid_connect

Elixir+ErlangでGoogleのOpenID Connect id_tokenを検証する

まえがき

まずOpenID Connect (OIDC)については、 @TakahikoKawasaki さんが大変充実した日本語記事をいくつも書いてくださっているので、そちらを参考にしましょう。

ざっというと、認可サーバはOAuth2.0の認可フローが開始される際に指定があると1、該当するフローでのレスポンスにid_tokenという新たな値を付与してくるようになります。

このid_tokenの中に、認可サーバでのユーザ認証が成功することで確定する、認可したユーザに関するessentialな情報(大抵の場合はemail, name, user_idなど、ユーザの識別子とするには最低限必要な情報)が入っており、かつ、認可サーバの秘密鍵によって署名が施されているため2、クライアントアプリ側でそれを検証することで、「確実に認可サーバのお墨付きを得た認証済みユーザの情報」がOAuth2.0のフローのついでに手にはいることになります。大変便利です。

これがないと、OAuth2.0でアクセストークンを手に入れたあと、そのアクセストークンでユーザ情報を取得するためのAPIを叩いて改めて詳細なユーザ情報を得る、という一手間が必要になりますし、このあたりに不適切な実装やフローの誤解に基づく脆弱性を仕込みがちとのことです。

GoogleのOIDC

さて、GoogleはOAuth2.0による認可フローに対応していますが、同時にOIDCにも対応しており、要求すればid_tokenを返してくれます。

つまりこれを使えば、Google People APIに問い合わせる手間なしに、ユーザのemailとname, ポートレイト画像URLくらいは安全に手に入るわけなので、ラウンドトリップを減らしつつ自分のアプリにセキュアなGoogle Loginを実現できます。是非使ってみたい。

id_tokenを手に入れる部分までは簡単なので省略します。既にOAuth2.0フローを扱ったことがあるならば既存のコードそのままに、response_typeid_tokenを含めて、かつscopeopenid email profileあたりを必要に応じて指定するだけです。認可コードフローであれば、トークンエンドポイントにcodeを渡したとき、いつものaccess_tokenと一緒にid_tokenももらえます。

こちらの記事も参考にしました。

Googleが発行するid_tokenは署名されているだけ(JWS)で暗号化はされていない(JWEではない)ので、単にpayload segmentをBase64URLデコードするだけでユーザ情報自体は閲覧可能ですが、署名検証をしないのではせっかくのセキュアな仕組みが台無しですので、当然署名検証をしましょう。

ここからが本題ですが、筆者は普段サーバサイドはElixirで書いているので、Elixir+ErlangでGoogleのid_token署名検証をしてみます。

Elixir+Erlangでid_token署名検証

先に書いておきますが、OIDCで使われるJWTの署名や暗号化の方法はたくさんあり、プロバイダが適宜選択・変更できるものなので、基本的にはRFCに存在している方式を一通りきちんと実装した専用のライブラリを使いましょう。本記事はあくまで現時点でのGoogle OIDCのid_tokenを自分で署名検証してみるならこんな感じ、という内容になります。

Elixir/Erlangではjoseがあり、READMEによればほとんどのアルゴリズムに対応している模様です。

後発ですがOpenID certifiedなoidccというのもあるようです。

一方、ベースとなるOAuth2.0のフロー処理はなんでも構いません。筆者が触ってみたことがあって、問題なさそうなのは以下の2つですが、特定プロバイダのみの対応であれば自前実装でもいいと思います。

当然サーバもPhoenixでも生Plugでも生cowboyでもなんでもいいです。ここでは対象外なので省略。

サンプル

とりあえず適当にデバッグプリントを仕込んだ上で、認可フローを開始してid_tokenを手に入れましょう。
こんな形をしています。module内にはっつけておいて、動作テストに使います。

  @sample """
  eyJhbGciOiJSUzI1NiIsImtpZCI6Ijk3OGNhNDExOGJmMTg4M2IzMTZiYmNhNmNlOTA0NGQ5OTc3ZjIw
  MjcifQ.
  eyJhenAiOiIxOTkzODM4MzIwNjUta2dqbzJmc2hsMTd0aGNpODNwN2IydGJocGlhdWEydjEuYXBwcy5n

  <snip>

  ZmFtaWx5X25hbWUiOiJNYXRzdXphd2EiLCJsb2NhbGUiOiJqYSJ9.
  cuCTQxfK9CLkL_EsqOFbDLhyhdJSyllgmUiffL0ZZgVVZZjwbMQnI4kTsl15aBKMJlmP1q36e3JNPT_d
  qZPNZ0KGm27mCPssYXkc75AO5h4P-fEXelfYzW3WwQ_Akd3i6uB0Ws-wE1UZFAkVrrSzs62GTbeJMHtA
  OU6fv1Ig20kE9z6KQTO096zbQgXpEyubj0p4hDRLQIrON_V5g1dG0QJrp6Onj4Olu5_-Wk1y5FkVAejr
  88TN1LnW99H_Pb0OhN0PU9Pe0Q4yENxXuE-grxhuU34yJPAGSlBHiw5RYl7y_PL4WmPdbuuCOzhVp7xt
  IN4u8mWnG4eJZ-PKJhA5vA
  """ |> String.replace("\n", "") |> String.trim()

コンフィデンシャルな情報は含んでいないので別に全て貼ってもいいのですが、一応payload segmentはsnipしました。

分割

String.split(id_token, ".")するだけですね。header, payload, signatureに分かれます。より厳密に書くとこうですね。

case String.split(id_token, ".", trim: false) do
  [header, payload, ""] ->
    parse_and_verify_unsecure_jws(header, payload)
  [header, payload, signature] ->
    parse_and_verify_secure_jws(header, payload, signature)
  [header, encrypted_key, iv, ciphertext, authentication_tag] ->
    parse_and_verify_jwe(header, encrypted_key, iv, ciphertext, authentication_tag)
end

もちろん今回はGoogleだけなので、2番めの分岐だけを実装すれば良く、他はおまけです。Googleの仕様が変わったときに検知できるようになっていれば良いでしょう。クラッシュでもいいと思います。
trim: falseはデフォルトと同じなので、明示したというだけです。Signatureパートが空文字列の場合はUnsecure JWSになりますが、Googleが選ぶことはまずないんではなかろうか。

デコード

Header部分を読んでみましょう。

iex> Base.url_decode64("eyJhbGciOiJSUzI1NiIsImtpZCI6Ijk3OGNhNDExOGJmMTg4M2IzMTZiYmNhNmNlOTA0NGQ5OTc3ZjIwMjcifQ", padding: false)
{:ok,
 "{\"alg\":\"RS256\",\"kid\":\"978ca4118bf1883b316bbca6ce9044d9977f2027\"}"}

アルゴリズムはRS256、つまりRSASSA-PKCS1-v1_5 using SHA-256方式です。詳細はリンクした参考記事に譲りますが、非対称暗号であるRSAを使った電子署名で、ダイジェスト(ハッシュ)にSHA256を用いたものです(情報全く増えてない)。
Payloadパートも同じように読めます。ElixirのBase.url_decode64/2の場合padding: falseが必要です。

署名検証

メインパートです。

公開鍵取得

RSAを用いた電子署名の検証ですから、当然RSA鍵ペアの公開鍵が必要になります。これはGoogleの認可サーバが取得用エンドポイントを公開しているのでgunhackneyHTTPoisonなどのHTTP clientで取ってきましょう。

公開鍵エンドポイント(jwks_uri)はhttps://www.googleapis.com/oauth2/v3/certsで、めったに変わることはないと思うのでソースコードに書いてしまってもいいと思いますが、ディスカバリーエンドポイントから解決することもできます。その場合はhttps://accounts.google.com/.well-known/openid-configurationに聞きに行きましょう。

ここではhackneyの例:

iex(18)> :hackney.get("https://www.googleapis.com/oauth2/v3/certs", [], "", with_body: true)
{:ok, 200,
 [{"Expires", "Fri, 19 Jan 2018 17:21:33 GMT"},
  {"Date", "Fri, 19 Jan 2018 10:34:19 GMT"}, {"Vary", "Origin"},
  {"Vary", "X-Origin"}, {"Content-Type", "application/json; charset=UTF-8"},
  {"X-Content-Type-Options", "nosniff"}, {"X-Frame-Options", "SAMEORIGIN"},
  {"X-XSS-Protection", "1; mode=block"}, {"Content-Length", "1957"},
  {"Server", "GSE"},
  {"Cache-Control", "public, max-age=24434, must-revalidate, no-transform"},
  {"Age", "11147"},
  {"Alt-Svc",
   "hq=\":443\"; ma=2592000; quic=51303431; quic=51303339; quic=51303338; quic=51303337; quic=51303335,quic=\":443\"; ma=2592000; v=\"41,39,38,37,35\""}],
 "{\n \"keys\": [\n  {\n   \"kty\": \"RSA\",\n   \"alg\": \"RS256\",\n   \"use\": \"sig\",\n   \"kid\": \"978ca4118bf1883b316bbca6ce9044d9977f2027\",\n   \"n\": \"qpe-lPi7HVP8_SRqodC19iWDcYJ-5-wZbBxxxgszoPbphgN8cUdcwOYuPoTT7BmDvezKhHq_JPjqxkJWO5_GESPw_ijMnXE3PddO1nNmWIBOxUBSE34LUf_GDsyXL6DmiiPsJtSdPgW4BzxkSf4VU-obP-K1BEyxmWwUJdUhNpUM7aj7aC-pCZJZyNF_OBjY5mq1lKn9kJvuy_EiSRvyCySR149lJW86K7VnbLGguu1pOo4s2JXf7nWGeccNydeJznY5FOi4tAxTiaGpM2gzXtS7gUDKgEKufE5V1fq2MtY-pYypRObZsesRit9CA3fQHQ5hrHhA4_uwLjhsVK0Z0w\",\n   \"e\": \"AQAB\"\n  },\n  {\n   \"kty\": \"RSA\",\n   \"alg\": \"RS256\",\n   \"use\": \"sig\",\n   \"kid\": \"9c37bf73343adb93920a7ae80260b0e57684551e\",\n   \"n\": \"rZ_JRz8H-Y5tD1bykrqicWgtGmlX_nGFl7NM_xq_P3vJwSYYeOVPXfrugYIbKZETPe3T3eBrXibgGkv4PdGB5j3jrEzqENkqZd3xSeTCrfv1SBLptzid7Y4dyeRyJGY0_GfrRb7yCMkeq-87KpwA6hww0aAQx5jc9tZBdv9XvS7efWhJtoeBrHhSOUMcaujBZst2V9_owud1i-WfOemSKZIXTkobENGLTbTOahZ0YU8jazq1jptWiAsyGlFIwOQR8e6dM38M9AgznGN8vggrS_NnW9RudicWQey19uOcUiMRCbEA2d6lfv0YGkQlOaAdQrpyi4fWieT1qR5BvVjHfQ\",\n   \"e\": \"AQAB\"\n  },\n  {\n   \"kty\": \"RSA\",\n   \"alg\": \"RS256\",\n   \"use\": \"sig\",\n   \"kid\": \"ac7ebbdff9e77669785f4c530fe2d4a6408bc98d\",\n   \"n\": \"1L2jYqXcdvdxtY10zT3PTZyTxG_gIScRcSheHYsuRMfdsh40xl_fBhpSfAIIlCwgyMz8_A03SueTw_jQE0z0JGezXi-1WiGpEJBY7Dmm78t5W6U7J-ktT3N7PtJgzS0gza8XVarGSkPc_J78PiRKmI4HJmw7d7nhHAEPJpH4RiMXIsOm6-HKEb_j_BdZJbiQRof67L8eAvIKNWi-8NsWKDK4UFE-8uddFo_E1_vjDdAfr99Rp45802_oN4sJNlgjU6s4vequyfFq-LQc5wXlt68ja3r_0TIaMCTFqO8ui-t1JGm1rS1qfCYLfxd3ZkhtVVDf2TtLrfC1rOTSzLxMUQ\",\n   \"e\": \"AQAB\"\n  },\n  {\n   \"kty\": \"RSA\",\n   \"alg\": \"RS256\",\n   \"use\": \"sig\",\n   \"kid\": \"24ce9b0ad16b4c597d4fd25c0886fccabbf77adf\",\n   \"n\": \"u3yCVkKHyRMy7kogpyO2t5W4-zY8-RcgGOSiYctxlGuszIZIFJ_enJJPn_4PKrTabRkyAV6tYHaOEd_A_UhBsGo-KAyUJS9Nzsk5vGLmJtANm0H_elwQKa50KEhRNYc8ZJhZNiXJuSrx2VwAiuo9u90M-1UF3lfL9_DJWK21_zJ255IH-HfaKS6EhWuWwsTN4C8mpCUvpRSPekv2cPtI9mbaQdlks52IMQxfhlwOCzmte5rVIfh6zpqWTnGB9KzsPT3mKfRXKEDpjQ1egoPT3lqaDm6YvFL4ZNWroVL9ZKit2pPZWiPv-hMo8UY3QjbH03EHU-OqtY6AOuaE1JLj7w\",\n   \"e\": \"AQAB\"\n  }\n ]\n}\n"}

ここまで、JSONをデコードせずにいました。PoisonでもjsoneでもなんでもOKです。デコードするとbodyの中身はこう:

%{"keys" => [%{"alg" => "RS256", "e" => "AQAB",
     "kid" => "978ca4118bf1883b316bbca6ce9044d9977f2027", "kty" => "RSA",
     "n" => "qpe-lPi7HVP8_SRqodC19iWDcYJ-5-wZbBxxxgszoPbphgN8cUdcwOYuPoTT7BmDvezKhHq_JPjqxkJWO5_GESPw_ijMnXE3PddO1nNmWIBOxUBSE34LUf_GDsyXL6DmiiPsJtSdPgW4BzxkSf4VU-obP-K1BEyxmWwUJdUhNpUM7aj7aC-pCZJZyNF_OBjY5mq1lKn9kJvuy_EiSRvyCySR149lJW86K7VnbLGguu1pOo4s2JXf7nWGeccNydeJznY5FOi4tAxTiaGpM2gzXtS7gUDKgEKufE5V1fq2MtY-pYypRObZsesRit9CA3fQHQ5hrHhA4_uwLjhsVK0Z0w",
     "use" => "sig"},
   %{"alg" => "RS256", "e" => "AQAB",
     "kid" => "3405d0ec4edf60539acf73be64604d49a097189a", "kty" => "RSA",
     "n" => "vBNfb9rmZLTwVpjoeT9lsLvzwl5rAVWGius9n2AFdibXlTaA_orGvSXL7l7SYLFcoxVGwNGrXDlAqwvpytyvOyRKcIepjbgRwADOAMbn4B4iQFlwI90dS_xqfGa6Ye6B-M6B802m0M43MJmZeEP9b81s2ExVPouE_zQz6-Pu1ZpABB2X7NaReVOzJAdOboRMQbZVh-X-HnYbM9PTWV-4fecQqE9sD_Qi8NSXiN1aC2n8DaIMjeEkDH5PJCPO6wmDUkolb2dIb3jryr19dGV0_Z-jRgzl8vdNw-u0o3mm0X8nj3cJSajjVEsTdbH35SdyFeu9Ob5G0oxwbPIBUr-2jw",
     "use" => "sig"},
   %{"alg" => "RS256", "e" => "AQAB",
     "kid" => "ac7ebbdff9e77669785f4c530fe2d4a6408bc98d", "kty" => "RSA",
     "n" => "1L2jYqXcdvdxtY10zT3PTZyTxG_gIScRcSheHYsuRMfdsh40xl_fBhpSfAIIlCwgyMz8_A03SueTw_jQE0z0JGezXi-1WiGpEJBY7Dmm78t5W6U7J-ktT3N7PtJgzS0gza8XVarGSkPc_J78PiRKmI4HJmw7d7nhHAEPJpH4RiMXIsOm6-HKEb_j_BdZJbiQRof67L8eAvIKNWi-8NsWKDK4UFE-8uddFo_E1_vjDdAfr99Rp45802_oN4sJNlgjU6s4vequyfFq-LQc5wXlt68ja3r_0TIaMCTFqO8ui-t1JGm1rS1qfCYLfxd3ZkhtVVDf2TtLrfC1rOTSzLxMUQ",
     "use" => "sig"},
   %{"alg" => "RS256", "e" => "AQAB",
     "kid" => "24ce9b0ad16b4c597d4fd25c0886fccabbf77adf", "kty" => "RSA",
     "n" => "u3yCVkKHyRMy7kogpyO2t5W4-zY8-RcgGOSiYctxlGuszIZIFJ_enJJPn_4PKrTabRkyAV6tYHaOEd_A_UhBsGo-KAyUJS9Nzsk5vGLmJtANm0H_elwQKa50KEhRNYc8ZJhZNiXJuSrx2VwAiuo9u90M-1UF3lfL9_DJWK21_zJ255IH-HfaKS6EhWuWwsTN4C8mpCUvpRSPekv2cPtI9mbaQdlks52IMQxfhlwOCzmte5rVIfh6zpqWTnGB9KzsPT3mKfRXKEDpjQ1egoPT3lqaDm6YvFL4ZNWroVL9ZKit2pPZWiPv-hMo8UY3QjbH03EHU-OqtY6AOuaE1JLj7w",
     "use" => "sig"}]}

Googleのdocによると、鍵ペアは更新されるものの1日に1回程度のペースだそうなので、必要に応じてキャッシュしても良いとのこと。

今回はkid: "978ca4118bf1883b316bbca6ce9044d9977f2027"のものを使用するので、

%{
  "alg" => "RS256",
  "e" => "AQAB",
  "kid" => "978ca4118bf1883b316bbca6ce9044d9977f2027",
  "kty" => "RSA",
  "n" => "qpe-lPi7HVP8_SRqodC19iWDcYJ-5-wZbBxxxgszoPbphgN8cUdcwOYuPoTT7BmDvezKhHq_JPjqxkJWO5_GESPw_ijMnXE3PddO1nNmWIBOxUBSE34LUf_GDsyXL6DmiiPsJtSdPgW4BzxkSf4VU-obP-K1BEyxmWwUJdUhNpUM7aj7aC-pCZJZyNF_OBjY5mq1lKn9kJvuy_EiSRvyCySR149lJW86K7VnbLGguu1pOo4s2JXf7nWGeccNydeJznY5FOi4tAxTiaGpM2gzXtS7gUDKgEKufE5V1fq2MtY-pYypRObZsesRit9CA3fQHQ5hrHhA4_uwLjhsVK0Z0w",
  "use" => "sig"
}

これですね。e値とn値が、それぞれRSAにおけるpublic exponentとmodulusになります。これもBase64URLエンコードされていることに注意しましょう。

:crypto.verify/5

いよいよ検証ですが、Erlangの:cryptoは大変充実しているので:crypto.verify/5でイッパツです。
署名対象文字列は単に<header>.<payload>なので、分割した文字列から組み上げましょう。

with {:ok, decoded_signature} <- Base.url_decode64(signature, padding: false),
     {:ok, decoded_e_value} <- Base.url_decode64(e_value, padding: false),
     {:ok, decoded_n_value} <- Base.url_decode64(n_value, padding: false)
do
  string_to_sign = header <> "." <> payload
  publickey = [decoded_e_value, decoded_n_value]
  if :crypto.verify(:rsa, :sha256, string_to_sign, decoded_signature, publickey) do
    {:ok, :verified}
  else
    {:error, :not_verified}
  end
end

終わってしまいました。簡単でしたね。
今後は業務・ホビー問わず使っていこうと思います。

おまけ

Payloadの中身

Googleで、scopeopenid email profileとした場合は以下のような内容が入ってきます。詳しくはdocを参照。

%{
  "at_hash" => "nGGwCzJKixMtcVUcc3CGww",
  "aud" => "<クライアントアプリのclient_id>",
  "azp" => "<クライアントアプリのclient_id>",
  "email" => "yu.matsuzawa@access-company.com",
  "email_verified" => true,
  "exp" => 1516358567,
  "family_name" => "Matsuzawa",
  "given_name" => "Yu",
  "hd" => "access-company.com",
  "iat" => 1516354967,
  "iss" => "https://accounts.google.com",
  "locale" => "ja",
  "name" => "Yu Matsuzawa",
  "picture" => "https://lh3.googleusercontent.com/-85zvVLP6GYE/AAAAAAAAAAI/AAAAAAAAABk/XTLw6Nnl93Y/s96-c/photo.jpg",
  "sub" => "108831527213653621065"
}

G Suite利用中の場合、hosted domain (hd)に関する確かな情報も得られるので、ユーザが特定ドメインに属しているかどうかのチェックに使っても良さそう。

現在利用可能なOpenID provider

の、ある程度権威付けられたリストがないかなあと思って軽くググったのですが、あまり良さげなのが見つからず。
OpenID FoundationのFAQによれば、

Some examples include Google, Gakunin (Japanese Universities Network), Microsoft, Ping Identity, Nikkei Newspaper, Tokyu Corporation, mixi, Yahoo! Japan and Softbank. There are also mature deployments underway by Working Group participant organizations, such as Deutsche Telecom, AOL, and Salesforce.

とのことです。Google/Microsoft/Yahoo/Salesforceあたりは業務で要請もありえそう。


  1. response_typeid_tokenを含める。code id_tokenなど。 

  2. Unsecure JWSという署名なし方式でない限りは。