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: