まえがき
まず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_type
にid_token
を含めて、かつscope
にopenid 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つですが、特定プロバイダのみの対応であれば自前実装でもいいと思います。
- ueberauth/ueberauth: An Elixir Authentication System for Plug-based Web Applications
- scrogson/oauth2: An Elixir OAuth 2.0 Client Library
当然サーバも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の認可サーバが取得用エンドポイントを公開しているのでgun
やhackney
、HTTPoison
などの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で、scope
をopenid 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あたりは業務で要請もありえそう。