pow-auth / assent

Multi-provider framework in Elixir
https://powauth.com
MIT License
391 stars 45 forks source link

Clarify apple usage #51

Closed tonnenpinguin closed 3 years ago

tonnenpinguin commented 4 years ago

Hi guys,

While implementing the endboss, aka sign in with Apple I ran into some issues.

The docs in Assent.Strategy.Apple specifically talk about the JS SDK, but I couldn't figure out how to get the state to be passed in and read back correctly.

In the end I resorted to just using the Routes.pow_assent_authorization_url/3, which worked like a charm.

Long story short, it would be great to give people a pointer to just using the regular /auth/apple/new flow, especially since the apple docs only talk about their JS SDK as well.

danschultzer commented 4 years ago

Yeah, the docs should describe this better. In the docs it's described how the state is generated with Assent.Strategy.Apple.authorize_url/1:

{:ok, %{session_params: %{state: state}} = Assent.Strategy.Apple.authorize_url(config)

You would want to store the :session_params in the user session, as that is what'll be used in the callback:

{:ok, %{user: user, token: token}} =
  config
  |> Assent.Config.put(:session_params, session_params)
  |> Assent.Strategy.Apple.callback(params)

In the Assent docs it doesn't show any example like this as it depends how you deal with the user session in your app. The above is essentially what PowAssent does for you. So if you are using PowAssent, you can just leverage it.

You can do something like the below in your controller to render the sign in with apple button (from the top of my head):

def new(conn, _params) do
  redirect_uri = Routes.pow_assent_authorization_url(conn, :callback, "apple")

  case PowAssent.Plug.authorize_url(conn, "apple", redirect_uri) do
    {:ok, _url, conn} ->
       conn
       |> PowAssent.Plug.put_session(conn, :session_params, conn.private[:pow_assent_session_params])
       |> render("new.html",
          state: conn.private[:pow_assent_session_params][:state],
          scope: "email",
          redirect_uri: redirect_uri,
          client_id: Application.get_env(:pow_assent, :my_app)[:providers][:apple][:client_id]
        ))

    {:error, error, conn} ->
       # handle error
  end
end

And render the button with:

<div id="appleid-signin" data-type="sign in"></div>
<script type="text/javascript" src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"></script>
<script type="text/javascript">
  AppleID.auth.init({
    clientId: '<%= @client_id %>',
    scope: '<%= @scope %>',
    redirectURI: '<%= @redirect_uri %>',
    state: '<%= @state %>'
  }) 
</script>

I'm not sure how this can be detailed better in the Assent docs though as it's very specific to your app (though most would be using Phoenix/Plug). I'm thinking that maybe it would be helpful to have strategy specific helpers in PowAssent, like rendering the Sign in with Apple button.

tonnenpinguin commented 4 years ago

Thanks for the elaborate answer!

Basically what I think would have been a good starting point is an explicitly pointer to pow_assent alongside the other usage infos.

When I first looked at the docs it seemed to me as if it was expected from me to implement the apple sign in with the JS SDK, rather than a pure phoenix flow.

That brings me to another thing I've been struggling with: Getting the users name.

Apple apparently decided not to include the user name in the identity token and also does not expose a user info endpoint. The only way I could come up with is to implement my own version of the Apple Strategy and then polyfill the user object that has been created based on the identity token with the parameters passed by apple as post attributes.

defmodule Auth.Strategy.Apple do
  use Assent.Strategy.OIDC.Base
  alias Assent.Strategy.Apple, as: AppleAssent

  @impl true
  def default_config(config) do
    config
    |> AppleAssent.default_config()
    |> Keyword.put(:authorization_params, scope: "email name", response_mode: "form_post")
  end

  def callback(config, params) do
    config
    |> AppleAssent.callback(params)
    |> polyfill_user_from_params(params)
  end

  defp polyfill_user_from_params({:ok, %{user: user} = results}, %{"user" => user_params}) do
    updated_user = update_user_name_from_params(user, user_params)
    {:ok, %{results | user: updated_user}}
  end

  defp polyfill_user_from_params(callback_result, _params), do: callback_result

  defp update_user_name_from_params(user, user_params_string)
       when is_binary(user_params_string) do
    with {:ok, user_params} <- Jason.decode(user_params_string) do
      update_user_name_from_params(user, user_params)
    end
  end

  defp update_user_name_from_params(user, %{
         "name" => %{"firstName" => first_name, "lastName" => last_name}
       }) do
    Map.put(user, "name", "#{first_name} #{last_name}")
  end

  defp update_user_name_from_params(user, _user_params), do: user

  defdelegate normalize(config, user), to: Assent.Strategy.Apple
end

Any chance you've come up with a more elegant solution to this?

danschultzer commented 3 years ago

Sorry, completely missed your reply! Your polyfill could be done with just adding a get_user/2 method:

  @impl true
  def get_user(config, token) do
    case OIDC.get_user(config, token) do
      {:ok, user}     -> {:ok, update_user_name_from_params(user, token)}
      {:error, error} -> {:error, error}
    end
  end

But really this is something the strategy should handle instead. All the strategies in assent should conform to standard claims format, so you only have to write logic for e.g. combining first and last name once and can use it for any strategies (like in the changeset). I've opened #55 to resolve this with the Apple strategy, let me know if it works for you @tonnenpinguin!

danschultzer commented 3 years ago

It has been merged to master so you can test it out by using {:assent, github: "pow-auth/assent", override: true}.