DEV Community

NDREAN
NDREAN

Posted on • Updated 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 a controller. 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.

For the HTTP client, you can use the default :httpc HTTP client or the default Finch provided by Phoenix; in this case, you need to specify the registered "name" used by this process (see **) in the compile config (and the callback URI, see below).

The other dependency is Joken/JOSE:

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

You will run a plug "checkCsrf" to check the CSRF token sent by Google against the one saved in the cookies, and then use the following function to decipher the JWT.

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.

You need to set up some HTML and a script from Google to render the One Tap button. You pass some assigns into the HTML, namely the callback URL to which Google will post a response, and the "google_client_id" of your project.

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". You use the Plug defined above to check the csrf against the one saved in conn.cookies["g_csrf_token"]. If the check is positive, the module below deciphers and checks the validity of this JWT token against Google's public key.

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
  def 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
  def 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

  # HTTP client `:httpc` to limit dependencies. Cf this post: https://elixirforum.com/t/httpc-cheatsheet/50337
  #defp fetch(url) do
  #  :inets.start()
  #  :ssl.start()
  #  headers = [{~c"accept", ~c"application/x-www-form-urlencoded"}]

  #  http_request_opts = [
  #    ssl: [
  #      verify: :verify_peer,
  #      cacerts: :public_key.cacerts_get(),
  #      customize_hostname_check: [
  #        match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
  #      ]
  #    ]
  #  ]
  #  case :httpc.request(:get, {~c"#{url}", headers}, http_request_opts, []) do
  #    {:ok, {{_version, 200, _}, _headers, body}} ->
  #      {:ok, body}

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

  # uses Phoenix default HTTP client "Finch" (**)
  defp fetch(url) do
    case Finch.build(:get, url) |> Finch.request(@registered_http_client) do
      {:ok, %{body: body}} ->
        {:ok, body}

      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.

CSRF check plug

defmodule MyApp.Plug.CheckCsrf 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

  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

Pipeline

With the new "Content-type" of "urlencoded", we define the following pipeline:

pipeline :google,
  plug Plug.Parsers,
      parsers: [:urlencoded],
      pass: ["text/html"]

  plug CheckCsrf
Enter fullscreen mode Exit fullscreen mode

and use it to parse Googles' response:

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

Example

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.

One Tap HTML

You will define a "login controller" that renders the One Tap button. You can get the HTML with Google's code generator.

<script src="https://accounts.google.com/gsi/client" async defer></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-size="large"
  data-theme="outline"
  data-text="sign_in_with"
  data-shape="pill"
  data-logo_alignment="left"
>
</div> 
Enter fullscreen mode Exit fullscreen mode

About the login endpoint

It is used in 3 places.

  • the callback URL (absolute) in the Google library API when you register your app to get the apps credentials.

https://example.com/g_cb_uri

  • the URI is of course declared in your router as a POST endpoint in the :google pipeline (it is "api" only, in order not to accept cookies, cf Sobelow)

  • in the compiled config (**). Your app will use it as an assign.

config :my_app, 
   g_cb_uri: "/g_cb_uri",
   http_client_name: App.Finch
Enter fullscreen mode Exit fullscreen mode

Login controller

You will need to populate two conn.assigns in a controller. We named it LoginController here.

# login_controller.ex
def login(conn, _) do
  g_cb_uri =
    Path.join(
      App.Endpoint.url(),
      Application.get_env(:my_app, :g_cb_uri)
    )
    ...
    conn
      |> fetch_session()
      |> assign(conn, :g_cb_uri, g_cb_uri)
      |> assign(conn, :g_client_id, System.get_env("GOOGLE_CLIENT_ID"))
    ...
Enter fullscreen mode Exit fullscreen mode

Callback controller

The callback controller digests Googles' payload after running the checkCSRF plug.

# one_tap_controller.ex
use MyAppWeb, :controller

def handler(conn, params) when _map_size(params) == 0 do
  conn
    |> fetch_session()
    |> fetch_flash()
    |> put_flash(:error, "Protocol error, please contact the maintainer")
    |> redirect(to: ~p"/")
end

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

   case App.ElixirGoogleCerts.verified_identity(%{jwt: jwt}) do
   {:ok, profile} ->
     ...
  end
end
Enter fullscreen mode Exit fullscreen mode

Latest 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: