DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

NDREAN
NDREAN

Posted on • Updated on

Google Login One tap backend for Elixir-Phoenix

A quick write on how to use Google's One tap login with Elixir. This gives an authentication tool in minutes.
There is no Elixir library to verify the JWT sent by Google so the code below could be a base to the missing Elixir library for Google One tap. This is largely inspired by this answer. It is based on the Jose/Joken library.

This module exposes one function which takes the conn, the jwt token and the anti-CSRF token received from Google.

ElixirGoogleCerts.verified_identity(conn, jwt, g_csrf_token)
Enter fullscreen mode Exit fullscreen mode

See the relevant doc.

This module uses Joken (and Jason and HTTPoison or equivalent).

defmodule ElixirGoogleCerts do

  @g_certs3_url "https://www.googleapis.com/oauth2/v3/certs"
  @iss "https://accounts.google.com"


  def verified_identity(conn, jwt, g_csrf_token) do
     with :ok <- double_token_check(conn, g_csrf_token),
          {:ok,
            %{
              "aud" => aud,
              "azp" => azp,
              "email" => email,
              "iss" => iss,
              "name" => name,
              "picture" => pic,
              "given_name" => given_name,
              "sub" => sub
            }} <- check_identity(jwt),
         true <- check_user(aud, azp),
         true <- check_iss(iss) do
      {:ok, %{email: email, name: name, google_id: sub, picture: pic, given_name: given_name}}
    else
      {:error, msg} -> {:error, msg}
      false -> {:error, :wrong_check}
    end
  end


  defp check_identity(jwt) do
    case Joken.peek_header(jwt) do
      {:error, msg} ->
        {:error, msg}

      {:ok, %{"kid" => kid, "alg" => alg}} ->
        %{"keys" => certs} =
          @g_certs3_url
          |> HTTPoison.get!()
          |> Map.get(:body)
          |> Jason.decode!()

        cert = Enum.find(certs, fn cert -> cert["kid"] == kid end)

        signer = Joken.Signer.create(alg, cert)

        Joken.verify(jwt, signer, [])
    end
  end

  # token in body is equal to received cookie
  defp double_token_check(conn, g_csrf_token) do
    case conn.cookies do
      %{"g_csrf_token" => g_cookie} ->
        if g_cookie == g_csrf_token,
          do: :ok,
          else: {:error, "Failed to verify double submit cookie."}

      _ ->
        {:error, "No cookie"}
    end
  end

  # ---- Google post-checking recommendations
  defp check_user(aud, azp) do
    aud == aud() || azp == aud()
  end

  defp check_iss(iss), do: iss == @iss
  defp aud, do: System.get_env("GOOGLE_CLIENT_ID")
end
Enter fullscreen mode Exit fullscreen mode

We firstly check that the anti-CSRF token received in the response body and in the cookie header are equal.
Then, on each call, we retrieve Google's certs (JWK in our case).
Then we use this "cert" (more precisely one out of the two sent) to certify the received JWT. Then we can extract the data and perform the extra checks as per Google's recommendations.

this may not be optimal as we have to reach Google for the certs on each call. This can be optimised by saving the certs and only relying on the Cache-Control to renew it. This package can be useful for this purpose.

Example

To use One tap, you firstly need to setup an app in the Google console and get credentials. You also have to enter a URI for Google to post you back a response.

Then add this HTML snippet that uses Google's SDK:

<script src="https://accounts.google.com/gsi/client" async defer></script>
<div id="g_id_onload"
  data-client_id={System.get_env("GOOGLE_CLIENT_ID")}
  data-login_uri="http://localhost:4000/auth/one_tap"
  data-auto_prompt="true"
  >
</div>
<div class="g_id_signin"
  data-type="standard"
  data-size="large"
  data-theme="outline"
  data-text="sign_in_with"
  data-shape="pill"
  data-logo_alignment="left"
  data-width="400">
</div> 
Enter fullscreen mode Exit fullscreen mode

The login endpoint you defined while registering your app is set above in the dataset login_uri. You can also use Javascript to fill with the context:

const oneTap = document.querySelector("#g_id_onload");

if (oneTap) {
  oneTap.dataset.login_uri = window.location.href + "/auth/one_tap";
}
Enter fullscreen mode Exit fullscreen mode

Then declare your endpoint in your router and define a callback in a controller:

pipeline :api do
  plug :accepts, ["json"]

  post("/auth/one_tap", AppWeb.OneTapController, :handler)
end
Enter fullscreen mode Exit fullscreen mode

The controller will digest the payload with the ElixirGoogeCerts.verified_identity/3 function above.

Below is an example:

# one_tap_controller.ex

action_fallback LoginErrorController

def handler(conn, %{"credential" => jwt,  "g_csrf_token" => g_csrf_token}) do
    with {:ok, profile} <-
         App.ElixirGoogleCerts.verified_identity(conn, jwt, g_csrf_token) do
    conn
    |> fetch_session()
    |> put_session(:profile, profile)
    |> redirect(to: Routes.welcome_path(conn, :index))
  end
end
Enter fullscreen mode Exit fullscreen mode

where the endpoint "/welcome" comes with controller/view/template.

Top comments (1)

Collapse
 
taronull profile image
Taro

How did you get around Phoenix complaining invalid CSRF?

Why You Need to Study Javascript Fundamentals

The harsh reality for JS Developers: If you don't study the fundamentals, you'll be just another β€œCoder”. Top learnings on how to get to the mid/senior level faster as a JavaScript developer by Dragos Nedelcu.