diff --git a/README.md b/README.md index 2610d76..11cf8b9 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Use Google, Github, Twitter, Facebook, or add your custom strategy for authoriza * [OAuth 1.0](lib/pow_assent/strategies/oauth.ex) * [OAuth 2.0](lib/pow_assent/strategies/oauth2.ex) * Includes the following provider strategies: + * [Azure AD](lib/pow_assent/strategies/azure_oauth2.ex) * [Basecamp](lib/pow_assent/strategies/basecamp.ex) * [Discord](lib/pow_assent/strategies/discord.ex) * [Facebook](lib/pow_assent/strategies/facebook.ex) diff --git a/lib/pow_assent/strategies/azure_oauth2.ex b/lib/pow_assent/strategies/azure_oauth2.ex new file mode 100644 index 0000000..ef783c5 --- /dev/null +++ b/lib/pow_assent/strategies/azure_oauth2.ex @@ -0,0 +1,98 @@ +defmodule PowAssent.Strategy.AzureOAuth2 do + @moduledoc """ + Azure AD OAuth 2.0 strategy. + + ## Usage + + config :my_app, :pow_assent, + providers: + [ + azure: [ + client_id: "REPLACE_WITH_CLIENT_ID", + client_secret: "REPLACE_WITH_CLIENT_SECRET", + strategy: PowAssent.Strategy.AzureOAuth2 + ] + ] + + A tenant id can be set to limit scope of users who can get access (defaults + to "common"): + + config :my_app, :pow_assent, + providers: + [ + azure: [ + client_id: "REPLACE_WITH_CLIENT_ID", + client_secret: "REPLACE_WITH_CLIENT_SECRET", + tenant_id: "8eaef023-2b34-4da1-9baa-8bc8c9d6a490", + strategy: PowAssent.Strategy.AzureOAuth2, + ] + ] + + The resource that client should pull a token for defaults to + `https://graph.microsoft.com/`. It can be overridden with the + `resource` key (or the `authorization_params` key): + + config :my_app, :pow_assent, + providers: + [ + azure: [ + client_id: "REPLACE_WITH_CLIENT_ID", + client_secret: "REPLACE_WITH_CLIENT_SECRET", + tenant_id: "8eaef023-2b34-4da1-9baa-8bc8c9d6a490", + resource: "https://service.contoso.com/", + strategy: PowAssent.Strategy.AzureOAuth2 + ] + ] + + ## Setting up Azure AD + + Login to Azure, and set up a new application: + https://docs.microsoft.com/en-us/azure/active-directory/develop/v1-protocols-oauth-code#register-your-application-with-your-ad-tenant + + * `client_id` is the "Application ID". + * `client_secret` has to be created with a new key for the application. + * The callback URL (http://localhost:4000/auth/azure/callback) should be + added to Reply URL's for the application + * "Sign in and read user profile" permission has to be enabled. + * To find the App ID URI to be used for `resource`, in the Azure Portal, + click Azure Active Directory, click Application registrations, open the + application's Settings page, then click Properties. + """ + use PowAssent.Strategy.OAuth2.Base + + @spec default_config(Keyword.t()) :: Keyword.t() + def default_config(config) do + tenant_id = Keyword.get(config, :tenant_id, "common") + resource = Keyword.get(config, :resource, "https://graph.microsoft.com/") + + [ + site: "https://login.microsoftonline.com", + authorize_url: "/#{tenant_id}/oauth2/authorize", + token_url: "/#{tenant_id}/oauth2/token", + authorization_params: [response_mode: "query", response_type: "code", resource: resource], + get_user_fn: &get_user/2 + ] + end + + @spec normalize(Client.t(), Keyword.t(), map()) :: {:ok, map()} + def normalize(_client, _config, user) do + {:ok, %{ + "uid" => user["sub"], + "name" => user["name"], + "email" => user["email"], + "first_name" => user["given_name"], + "last_name" => user["family_name"]}} + end + + @spec get_user(Keyword.t(), Client.t()) :: {:ok, map()} + def get_user(_config, client) do + user = + client.token.other_params["id_token"] + |> String.split(".") + |> Enum.at(1) + |> Base.decode64!(padding: false) + |> Poison.decode!() + + {:ok, user} + end +end diff --git a/test/pow_assent/strategies/azure_oauth2.exs b/test/pow_assent/strategies/azure_oauth2.exs new file mode 100644 index 0000000..431c5ce --- /dev/null +++ b/test/pow_assent/strategies/azure_oauth2.exs @@ -0,0 +1,51 @@ +defmodule PowAssent.Strategy.AzureOAuth2 do + use PowAssent.Test.Phoenix.ConnCase + + import OAuth2.TestHelpers + alias PowAssent.Strategy.AzureOAuth2 + + @access_token "access_token" + + setup %{conn: conn} do + bypass = Bypass.open() + config = [site: bypass_server(bypass)] + + {:ok, conn: conn, config: config, bypass: bypass} + end + + test "authorize_url/2", %{conn: conn, config: config} do + assert {:ok, %{conn: _conn, url: url}} = AzureOAuth2.authorize_url(config, conn) + assert url =~ "/common/oauth2/authorize?client_id=" + + config = Keyword.put(config, :tenant_id, "8eaef023-2b34-4da1-9baa-8bc8c9d6a490") + assert {:ok, %{conn: _conn, url: url}} = AzureOAuth2.authorize_url(config, conn) + assert url =~ "/8eaef023-2b34-4da1-9baa-8bc8c9d6a490/oauth2/authorize?client_id=" + end + + describe "callback/2" do + setup %{conn: conn, config: config, bypass: bypass} do + params = %{"code" => "test", "redirect_uri" => "test"} + + {:ok, conn: conn, config: config, params: params, bypass: bypass} + end + + test "normalizes data", %{conn: conn, config: config, params: params, bypass: bypass} do + Bypass.expect_once(bypass, "POST", "/common/oauth2/token", fn conn -> + id_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJhdWQiOiIyZDRkMTFhMi1mODE0LTQ2YTctODkwYS0yNzRhNzJhNzMwOWUiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC83ZmU4MTQ0Ny1kYTU3LTQzODUtYmVjYi02ZGU1N2YyMTQ3N2UvIiwiaWF0IjoxMzg4NDQwODYzLCJuYmYiOjEzODg0NDA4NjMsImV4cCI6MTM4ODQ0NDc2MywidmVyIjoiMS4wIiwidGlkIjoiN2ZlODE0NDctZGE1Ny00Mzg1LWJlY2ItNmRlNTdmMjE0NzdlIiwib2lkIjoiNjgzODlhZTItNjJmYS00YjE4LTkxZmUtNTNkZDEwOWQ3NGY1IiwidXBuIjoiZnJhbmttQGNvbnRvc28uY29tIiwidW5pcXVlX25hbWUiOiJmcmFua21AY29udG9zby5jb20iLCJzdWIiOiJKV3ZZZENXUGhobHBTMVpzZjd5WVV4U2hVd3RVbTV5elBtd18talgzZkhZIiwiZmFtaWx5X25hbWUiOiJNaWxsZXIiLCJnaXZlbl9uYW1lIjoiRnJhbmsifQ." + + send_resp(conn, 200, Poison.encode!(%{access_token: @access_token, id_token: id_token})) + end) + + expected = %{ + "uid" => "JWvYdCWPhhlpS1Zsf7yYUxShUwtUm5yzPmw_-jX3fHY", + "name" => "Frank Miller", + "given_name" => "Frank", + "family_name" => "Miller", + "email" => "frank@contoso.com", + } + + {:ok, %{user: user}} = AzureOAuth2.callback(config, conn, params) + assert expected == user + end + end +end