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

# まえがき

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

- [一番分かりやすい OpenID Connect の説明](https://qiita.com/TakahikoKawasaki/items/498ca08bbfcc341691fe#_reference-81690db08ccaec0d0950)
- [[前編] IDトークンが分かれば OpenID Connect が分かる](https://qiita.com/TakahikoKawasaki/items/8f0e422c7edd2d220e06)
- [OpenID Connect 全フロー解説](https://qiita.com/TakahikoKawasaki/items/4ee9b55db9f7ef352b47)

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

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

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

[^1]: `response_type`に`id_token`を含める。`code id_token`など。
[^2]: Unsecure JWSという署名なし方式でない限りは。

# GoogleのOIDC

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

- [OpenID Connect  |  Google Identity Platform  |  Google Developers](https://developers.google.com/identity/protocols/OpenIDConnect#discovery)

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

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

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

- [OpenID Connect の JWT の署名を自力で検証してみると見えてきた公開鍵暗号の実装の話](https://qiita.com/bobunderson/items/d48f89e2b3e6ad9f9c4c#%E5%85%AC%E9%96%8B%E9%8D%B5%E3%81%AE%E5%85%A5%E6%89%8B)

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][jose]があり、READMEによればほとんどのアルゴリズムに対応している模様です。

- [potatosalad/erlang-jose: JSON Object Signing and Encryption (JOSE) for Erlang and Elixir][jose]

[jose]: https://github.com/potatosalad/erlang-jose

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

- [indigo-dc/oidcc: OpenId Connect client library in Erlang](https://github.com/indigo-dc/oidcc)

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

- [ueberauth/ueberauth: An Elixir Authentication System for Plug-based Web Applications](https://github.com/ueberauth/ueberauth)
- [scrogson/oauth2: An Elixir OAuth 2.0 Client Library](https://github.com/scrogson/oauth2)

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

## サンプル

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

```ex
  @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`に分かれます。より厳密に書くとこうですね。

```ex
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部分を読んでみましょう。

```ex
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の認可サーバが取得用エンドポイントを公開しているので`gun`や`hackney`、`HTTPoison`などのHTTP clientで取ってきましょう。

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

ここではhackneyの例：

```ex
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の中身はこう：

```ex
%{"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](https://developers.google.com/identity/protocols/OpenIDConnect#validatinganidtoken)によると、鍵ペアは更新されるものの1日に1回程度のペースだそうなので、必要に応じてキャッシュしても良いとのこと。

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

```ex
%{
  "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`][verify]

[verify]: http://erlang.org/doc/man/crypto.html#verify-5

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

```ex
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で、`scope`を`openid email profile`とした場合は以下のような内容が入ってきます。詳しくは[doc](https://developers.google.com/identity/protocols/OpenIDConnect#obtainuserinfo)を参照。

```ex
%{
  "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](http://openid.net/connect/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あたりは業務で要請もありえそう。