DEV Community

NDREAN
NDREAN

Posted on • Edited on

Google Login One tap backend for Elixir-Phoenix

This shows how to integrate Google's One tap login in an Elixir/Phoenix app in LiveView.
This is largely inspired by this thread. This gives a secured authentication tool in minutes.

Updated Aug-23: remove the "nonce": https://developers.google.com/identity/gsi/web/guides/fedcm-migration

Update: Oct-23 Google sends a POST request with "Content-type" of "application/x-www-form-urlencoded" instead of "application/json" format.

Update: June-25. Use in a LiveView with the generated mix phx.gen.auth Accounts User users.

For the HTTP client, we use the HTTP client Req.

The other dependency is Joken/JOSE:

Mix.install([{:joken, "~> 2.6"}])
Enter fullscreen mode Exit fullscreen mode

When the user clicks on the button, he will get a form. It is pre-filed if a Google session is active. Otherwise, he will follow the standard Google verification procedure, so Google verifies this entry for you.

When the submission is successful, Google will post a JWT to your endpoint and the "g_csrf_token".

GoogleCert module

It exposes the function:

ElixirGoogleCerts.verified_identity/1
Enter fullscreen mode Exit fullscreen mode

It takes a map %{jwt: jwt} and returns a tuple {:ok, profil} or {:error, reason}.

The module checks the returned JWT against the Google public keys.

About Google public keys. The "v1" endpoint is a JSON object in PEM format whilst the "v3" is in JSON Web Key (JWK) format. Both versions can be used. See the relevant doc.

defmodule ElixirGoogleCerts do

  # (**): define in "/config/config.exs"
  @registered_http_client Application.compile_env!(:my_app, :http_client_name)

  @json_lib Phoenix.json_library()
  @pem_certs "https://www.googleapis.com/oauth2/v1/certs"
  @jwk_certs "https://www.googleapis.com/oauth2/v3/certs"
  @iss "https://accounts.google.com"

  def verified_identity(%{jwt: jwt}) do
    with {:ok, profile} <- check_identity_v1(jwt),
         {:ok, true} <- run_checks(profile) do
      {:ok, profile}
    else
      {:error, msg} -> {:error, msg}
    end
  end

  # PEM version
  defp check_identity_v1(jwt) do
    with {:ok, %{"kid" => kid, "alg" => alg}} <- Joken.peek_header(jwt),
         {:ok, body} <- fetch(@pem_certs) do
      {true, %{fields: fields}, _} =
        body
        |> @json_lib.decode!()
        |> Map.get(kid)
        |> JOSE.JWK.from_pem()
        |> JOSE.JWT.verify_strict([alg], jwt)

      {:ok, fields}
    else
      {:error, reason} -> {:error, inspect(reason)}
    end
  end

  # JWK version
  defp check_identity_v3(jwt) do
    with {:ok, %{"kid" => kid, "alg" => alg}} <- Joken.peek_header(jwt),
         {:ok, body} <- fetch(@jwk_certs) do
      %{"keys" => certs} = @json_lib.decode!(body)
      cert = Enum.find(certs, fn cert -> cert["kid"] == kid end)
      signer = Joken.Signer.create(alg, cert)
      Joken.verify(jwt, signer, [])
    else
      {:error, reason} -> {:error, inspect(reason)}
    end
  end


  # uses Phoenix default HTTP client "Req" (**)
  defp fetch(url) do
    case Req.get(url) do
      {:ok, %{body: body}} ->
        {:ok, body}

      {:error, error} ->
        {:error, error}
    end
  end


  # ---- Google recommendations

  def run_checks(claims) do
    %{
      "exp" => exp,
      "aud" => aud,
      "azp" => azp,
      "iss" => iss
    } = claims

    with {:ok, true} <- not_expired(exp),
         {:ok, true} <- check_iss(iss),
         {:ok, true} <- check_user(aud, azp) do
      {:ok, true}
    else
      {:error, message} -> {:error, message}
    end
  end

  def not_expired(exp) do
    case exp > DateTime.to_unix(DateTime.utc_now()) do
      true -> {:ok, true}
      false -> {:error, :expired}
    end
  end

  def check_user(aud, azp) do
    case aud == app_id() || azp == app_id() do
      true -> {:ok, true}
      false -> {:error, :wrong_id}
    end
  end

  def check_iss(iss) do
    case iss == @iss do
      true -> {:ok, true}
      false -> {:ok, :wrong_issuer}
    end
  end

  defp app_id, do: System.get_env("GOOGLE_CLIENT_ID")
end
Enter fullscreen mode Exit fullscreen mode

! Note that on each call, we retrieve Google's certs ([PEM] or [JWK]). This may not be optimal and could be cached.

Google's CSRF check plug

This plug "PlugGoogleAuth" checks the received token against the one saved in conn.cookies["g_csrf_token"].

defmodule MyAppWeb.PlugGoogleAuth do
  @moduledoc """
  Plug to check the CSRF state concordance when receiving data from Google.

  Denies to treat the HTTP request if fails.
  """
  import Plug.Conn
  use MyAppWeb, :verified_routes
  use MyAppWeb, :controller

  def init(opts), do: opts

  def call(conn, _opts) do
    g_csrf_from_cookies =
      fetch_cookies(conn)
      |> Map.get(:cookies, %{})
      |> Map.get("g_csrf_token")

    g_csrf_from_params =
      Map.get(conn.params, "g_csrf_token")

    case {g_csrf_from_cookies, g_csrf_from_params} do
      {nil, _} ->
        halt_process(conn, "CSRF cookie missing")

      {_, nil} ->
        halt_process(conn, "CSRF token missing")

      {cookie, param} when cookie != param ->
        halt_process(conn, "CSRF token mismatch")

      _ ->
        conn
    end
  end

  defp halt_process(conn, msg) do
    conn
    |> fetch_session()
    |> Phoenix.Controller.fetch_flash()
    |> Phoenix.Controller.put_flash(:error, msg)
    |> Phoenix.Controller.redirect(to: ~p"/")
    |> halt()
  end
end
Enter fullscreen mode Exit fullscreen mode

Router Pipeline

pipeline :google_auth do
    plug :put_csp
    plug :put_secure_browser_headers, %{"referrer-policy" => "no-referrer-when-downgrade"}
    plug MyAppWeb.PlugGoogleAuth
  end
Enter fullscreen mode Exit fullscreen mode

Use it:

scope "/", MyApp do
  pipe_through: [:google_auth]
  post "/google_auth", OneTapController, :login
end
Enter fullscreen mode Exit fullscreen mode

Example in a Live Component

To use One tap, you firstly need to setup an app in the Google library API console and get credentials. You will enter the URL (absolute: scheme+host+URI) for Google to post back a response to your app.

When you test OneTap locally, you need to pass both "http:/:localhost:4000" AND "http://localhost" in the "Authorized Javascript Origin", and "http://localhost:4000/g_cb_uri" in the redirection URI.

We add a link to Google One Tap, eg in root.html.heex:

# root.html.heex

<li class="badge">
  <.link href={~p"/users/one-tap"}>One Tap</.link>
</li>
Enter fullscreen mode Exit fullscreen mode

You can define a live component:

# MyAppWeb.UserLive.OneTap.ex

use MyAppWeb, :live_view

def render(assigns) do
    ~H"""
    <div id="one-tap-login" phx-update="ignore">
      <script nonce={@csp_nonce} src="https://accounts.google.com/gsi/client" async>
      </script>

      <div
        id="g_id_onload"
        data-client_id={@g_client_id}
        data-login_uri={@g_cb_uri}
        data-auto_prompt="true"
      >
      </div>
      <div
        class="g_id_signin"
        data-type="standard"
        data-text="signin_with"
        data-shape="rectangular"
        data-theme="outline"
        data-size="large"
        data-logo_alignment="center"
        data-width="200"
      >
      </div>
    </div>
    """
  end

def mount(_params, %{"_csrf_token" => _csrf_token} = _session, socket) do
    callback_uri =
      Path.join(
        MyAppWeb.Endpoint.url(),
        Application.fetch_env!(:my_app, :google_callback_uri)
      )

    google_client_id =
      Application.fetch_env!(:my_app, :google_client_id)

    csp_nonce = Process.get(:nonce)

    socket =
      assign(socket,
        g_cb_uri: callback_uri,
        g_client_id: google_client_id,
        csp_nonce: csp_nonce
      )


    {:ok, socket}
  end
end
Enter fullscreen mode Exit fullscreen mode

Check Google's code generator.

Config

# config/config.exs

config :my_app, 
   google_callback_uri: "/google_auth",
Enter fullscreen mode Exit fullscreen mode
# config/runtime.exs

config :my_app,
  google_client_id:
    System.get_env("GOOGLE_CLIENT_ID") ||
      raise("""
      environment variable GOOGLE_CLIENT_ID is missing.
      You can generate one by going to https://console.cloud.google.com/apis/credentials
      and creating a new OAuth 2.0 Client ID.
      """),
  google_client_secret:
    System.get_env("GOOGLE_CLIENT_SECRET") ||
      raise("""
      environment variable GOOGLE_CLIENT_SECRET is missing.
      You can generate one by going to https://console.cloud.google.com/apis/credentials
      and creating a new OAuth 2.0 Client ID.
      """),
Enter fullscreen mode Exit fullscreen mode

Login controller

defmodule MyAppWeb.OneTapController do
  use MyAppWeb, :controller
  alias MyAppWeb.UserAuth
  alias MyApp.Accounts

  def handle(conn, %{"credential" => jwt} = _params) do

    case ExGoogleCerts.verified_identity(%{jwt: jwt}) do
      {:ok, profile} ->
        user =
          case Accounts.get_user_by_email(profile["email"]) do
            nil ->
              {:ok, user} =
                Accounts.register_user(%{
                  email: profile["email"],
                  confirmed_at: if(profile["email_verified"], do: DateTime.utc_now(), else: nil)
                })

              user

            user ->
              user
          end

        conn
        |> fetch_session()
        |> fetch_flash()
        |> put_flash(:info, "Google identity verified successfully.")
        |> UserAuth.log_in_user(user)

      {:error, reason} ->
        conn
        |> fetch_session()
        |> fetch_flash()
        |> put_flash(:error, "Google identity verification failed: #{reason}")
        |> redirect(to: ~p"/")
    end
  end

  def handle(conn, %{}) do
    conn
    |> fetch_session()
    |> fetch_flash()
    |> put_flash(:error, "Protocol error, please contact the maintainer")
    |> redirect(to: ~p"/")
  end
end
Enter fullscreen mode Exit fullscreen mode

Top comments (2)

Collapse
 
taronull profile image
Taro

How did you get around Phoenix complaining invalid CSRF?

Collapse
 
endoooo profile image
Eric Endo • Edited

you need to use a different pipeline without plug :protect_from_forgery and implement your own CSRF token verification:

defmodule InvalidGoogleCSRFTokenError do
  @moduledoc "Error raised when Google CSRF token is invalid."

  defexception [:message]

  @impl true
  def exception(message) do
    %InvalidGoogleCSRFTokenError{message: message}
  end
end

@doc """
Verify Google's CSRF token.
Reference: https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
"""
def verify_google_csrf_token(conn, opts) do
  csrf_token_cookie = conn.req_cookies |> Map.get("g_csrf_token", nil)

  if is_nil(csrf_token_cookie) do
    raise InvalidGoogleCSRFTokenError, "No CSRF token in Cookie."
  end

  csrf_token_body = conn.params |> Map.get("g_csrf_token", nil)

  if is_nil(csrf_token_cookie) do
    raise InvalidGoogleCSRFTokenError, "No CSRF token in post body."
  end

  if csrf_token_cookie != csrf_token_body do
    raise InvalidGoogleCSRFTokenError, "Failed to verify double submit cookie."
  end

  conn
end
Enter fullscreen mode Exit fullscreen mode

references: