この記事は Elixir Advent Calendar 2023 シリーズ 1 の 10 日目です。
@koyo-miyamura です!
最近は本業の傍ら、Elixirコミュニティの方で副業しており、実務でのElixir実装にハマっております。
「お仕事でも使える Elixir」をテーマに実務での実装例の紹介に励んでおりますb
最近では Bright という SaaS の開発に携わらせていただきました
ぜひ使ってみてください!
Elixir で OAuth2 したいなら ... ueberauth !
Elixir で OAuth2 認証したい場合、 ueberauth
というライブラリを使うことが多いかと思います。
ueberauth が本体で、各プロバイダ(GitHub など)ごとの Strategy を実装する設計になっています。
各プロバイダの Strategy もこのように公開されています。
簡単な例ですが、以下のようなリポジトリで例も公開されています。
上記を例にして実装すると大体ざっくりこんな感じになると思います。
コントローラーの request
メソッドで OAuth2 プロバイダが提供する認可エンドポイントへのリクエストを処理し、callback
メソッドで認可サーバーからのコールバックを処理します。
OAuth2 の細かい処理は plug Ueberauth
や Ueberauth.Strategy.Helpers
が、config
に設定された Strategy に応じて処理してくれることで共通化されています。
なので使う側は %Ueberauth.Failure{}
(失敗時)と %Ueberauth.Auth{}
(成功時)の 2 通りを処理するだけでよいです!とっても簡単!
config :ueberauth, Ueberauth,
providers: [
github: {Ueberauth.Strategy.Github, []}
]
config :ueberauth, Ueberauth.Strategy.Github.OAuth,
client_id: {:system, "GITHUB_CLIENT_ID"},
client_secret: {:system, "GITHUB_CLIENT_SECRET"}
pipeline :auth do
plug Ueberauth
end
scope "/auth", OauthSampleWeb do
pipe_through [:browser, :auth]
get "/:provider", AuthController, :request
get "/:provider/callback", AuthController, :callback
post "/:provider/callback", AuthController, :callback
end
defmodule OauthSampleWeb.AuthController do
@moduledoc """
Auth controller responsible for handling Ueberauth responses
"""
use OauthSampleWeb, :controller
alias Ueberauth.Strategy.Helpers
def request(conn, _params) do
redirect(conn, to: Helpers.request_url(conn))
end
def callback(%{assigns: %{ueberauth_failure: %Ueberauth.Failure{} = fails}} = conn, _params) do
IO.inspect(fails)
conn
|> put_flash(:error, "Failed to authenticate.")
|> redirect(to: "/")
end
def callback(%{assigns: %{ueberauth_auth: %Ueberauth.Auth{} = auth}} = conn, _params) do
IO.inspect(auth)
# セッションを生成
conn
|> put_flash(:info, "Succeed to authenticate!")
|> put_session(:current_user, auth.uid)
|> configure_session(renew: true)
|> redirect(to: "/")
end
end
テストは...?
ここまでは順調ですが...
「これで認証できたぞ~!なんて簡単なんだ!」
「さてさて、プロダクションで運用するなら単体テストも書かないとね」
「...あれ?どうやって書くんだ!?」
となります(私はなりました笑)。
単体テスト中に実際に外部の認可サーバーにリクエスト飛ばすわけにもいきませんし、何かしらの手段でモックが必要そうです。
また、ライブラリの単体テストをしたいわけではないので、OAuth2 の処理はライブラリがうまく処理してくれるとして、自分が記述した処理(成功時にセッションに uid を入れたりするなど)が動作しているかをテストしたいです。
(実際に OAuth2 が成功して一連のユーザーストーリーが実行できるかどうかは E2E テストでやればよいという方針)
「...流石にテスト用の何かあるでしょ」と思い、ドキュメントを読むもない...。
ついには以下の issue を見つけてしまいました。
なんと 2018年 の issue が open になっています!笑
つまり、ないということ...。
「こ、これでは困る!!!」
ということで英語記事中心に色々調べて自分なりのベストプラクティスを構築したので紹介します。
(自分が四苦八苦した時に、もしこんな記事があれば...と思ったので悲劇を繰り返さないためにも笑)
まず ueberauth はうまく抽象化された作りになっており、各プロバイダ毎の差異は Strategy に吸収されています。ライブラリを使う側はシンプルに、成功時と失敗時のケースをハンドリングすればよいです。
なので %Ueberauth.Auth{}
と %Ueberauth.Failure{}
をテスト中に、好きに差し替えることが出来ればよさそうです。
また、request
はリダイレクトをテストできればいいので、モックせずにそのままテストできると嬉しいです。
issue の方にもテスト用の Strategy を用意してみるとどうだ?と書いてあります。
ただ上記は少し色々やっているので、最低限必要な部分だけ抜き出してみようと思います。
自作のテスト用 Strategy を作る
ということで最低限の実装を抜き出したコードがこちらです。
これを test/support 以下に配置します。
defmodule OauthSample.Ueberauth.Strategy.Test do
use Ueberauth.Strategy
# NOTE: request は Strategy ごとにテストしたいので実際の Strategy モジュールを使用する
def handle_request!(conn), do: aliased_strategy(conn).handle_request!(conn)
defp aliased_strategy(%{private: %{ueberauth_request_options: %{options: opts}}} = _conn) do
Keyword.fetch!(opts, :aliased_strategy)
end
# NOTE: callback は Strategy に関わらず成功・失敗ケースをテスト中に指定してテストできるようにしたい
# また、実際の Strategy モジュールを使ってしまうと実際に OAuth プロバイダに HTTP リクエストしてしまうので避けたい
# よって上記の要件を満たすために何もせず返す
def handle_callback!(conn), do: conn
end
作成したテスト用の Strategy をテスト時のみ使うようにします。
# NOTE: テスト用に OauthSample.Ueberauth.Strategy.Test を作成して使用
config :ueberauth, Ueberauth,
providers: [
github:
{OauthSample.Ueberauth.Strategy.Test,
[aliased_strategy: Ueberauth.Strategy.Github, default_scope: ""]}
]
config :ueberauth, Ueberauth.Strategy.Github.OAuth,
client_id: "dummy_client_id",
client_secret: "dummy_client_secret"
これでテストが書けるようになりました!
実際にテストを書いてみる
それでは auth_controller
のテストを書いてみましょう!
defmodule OauthSampleWeb.AuthControllerTest do
use OauthSampleWeb.ConnCase
test "GET /auth/github", %{conn: conn} do
conn = get(conn, ~p"/auth/github")
# GitHub の OAuth パスにリダイレクトしていることをテスト
assert %URI{
path: "/login/oauth/authorize",
query: query
} = redirected_to(conn) |> URI.parse()
assert %{
"client_id" => "dummy_client_id",
"redirect_uri" => "http://www.example.com/auth/github/callback",
"response_type" => "code",
"scope" => "",
"state" => state
} = URI.decode_query(query)
# OAuth に必要な state パラメータが生成できていることを確認
assert is_binary(state)
end
defp setup_for_github_auth(%{conn: conn}) do
# NOTE: state パラメータ付きでリクエストしないと CSRF エラーになるので事前に生成しておく
conn =
conn
|> get(~p"/auth/github")
state =
redirected_to(conn)
|> URI.parse()
|> Map.get(:query)
|> URI.decode_query()
|> Map.get("state")
%{conn: conn |> recycle(), state: state, provider: :github}
end
describe "GET /auth/:provider/callback when provider is google" do
setup [:setup_for_github_auth]
# 失敗時のテスト
test "redirects with error message when OAuth is failure", %{
conn: conn,
state: state,
provider: provider
} do
ueberauth_failure = %Ueberauth.Failure{
errors: %Ueberauth.Failure.Error{
message: "error message",
message_key: "error message_key"
},
provider: provider,
strategy: Ueberauth.Strategy.Github
}
conn =
conn
|> assign(:ueberauth_failure, ueberauth_failure)
|> get(~p"/auth/github/callback", %{"state" => state})
# OAuth 失敗時の自前のロジックをここでテストできている!
assert redirected_to(conn) == ~p"/"
assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Failed to authenticate."
end
# 成功時のテスト
test "redirects and generate session when OAuth is success",
%{
conn: conn,
state: state,
provider: provider
} do
identifier = "1"
ueberauth_auth = %Ueberauth.Auth{
provider: provider,
uid: identifier
}
conn =
conn
|> assign(:ueberauth_auth, ueberauth_auth)
|> get(~p"/auth/google/callback", %{"state" => state})
# OAuth 成功時の自前のロジックをここでテストできている!
assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Succeed to authenticate!"
assert redirected_to(conn) == ~p"/"
assert get_session(conn, :current_user) == identifier
end
end
end
state パラメータを用意したりしないといけないので、そこがハマりポイントなのでご注意ください!
テスト実行してみると成功しました
$ mix test test/oauth_sample_web/controllers/auth_controller_test.exs
.%Ueberauth.Auth{
uid: "1",
provider: :github,
strategy: nil,
info: %Ueberauth.Auth.Info{
name: nil,
first_name: nil,
last_name: nil,
nickname: nil,
email: nil,
location: nil,
description: nil,
image: nil,
phone: nil,
birthday: nil,
urls: %{}
},
credentials: %Ueberauth.Auth.Credentials{
token: nil,
refresh_token: nil,
token_type: nil,
secret: nil,
expires: nil,
expires_at: nil,
scopes: [],
other: %{}
},
extra: %Ueberauth.Auth.Extra{raw_info: %{}}
}
.%Ueberauth.Failure{
provider: :github,
strategy: Ueberauth.Strategy.Github,
errors: %Ueberauth.Failure.Error{
message_key: "error message_key",
message: "error message"
}
}
.
Finished in 0.1 seconds (0.00s async, 0.1s sync)
3 tests, 0 failures
まとめ
上記のようにテストすることで
- OAuth2 プロバイダが提供する認可エンドポイントへのリクエスト
- 認可サーバーからのコールバック(認証失敗時)
- 認可サーバーからのコールバック(認証成功時)
がテストできています。
OAuth2 の実装自体はライブラリ依存ですが、認証成功・失敗時にどう振舞うかなどはアプリケーション固有なのでテストしたいです(しかも認証が絡むので、なおさらテストはしっかり書きたい)。
本記事の手法を用いることで、上記の要件を満たしつつ Ueberauth
を使ってお手軽に Elixir で OAuth2 の実装・テストが実現できました
本記事ではプロバイダに GitHub を使用していますが Google など他のプロバイダも Ueberauth から提供されていますので、ぜひトライしてみてください
引き続き、Elixir Advent Calendar 2023 をお楽しみください。