Google Login One tap backend for Elixir-Phoenix
Tags: elixir, phoenix
Published: 2022-10-25 22:22:22

This shows how to integrate Google's [One tap login](https://developers.google.com/identity/gsi/web/guides/display-google-one-tap) in an Elixir/Phoenix app in a controller. This is largely inspired by [this thread](https://elixirforum.com/t/using-joken-to-validate-google-jwts/19728). 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`:

```elixir
Mix.install([{:joken, "~> 2.5"}])
```


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

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](https://developers.google.com/identity/gsi/web/guides/verify-google-id-token).

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.

```elixir
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
```

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

```elixir
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
```

## Pipeline

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

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

  plug CheckCsrf
```

and use it to parse Googles' response:

```elixir
scope "/", MyApp do
  pipe_through: [:google]
  post "/g_cb_uri", OneTapController, :login
end
```

## 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](https://developers.google.com/identity/gsi/web/tools/configurator).

```html
<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> 
```

#### 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.

```elixir
config :my_app, 
   g_cb_uri: "/g_cb_uri",
   http_client_name: App.Finch
```


### Login controller

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

```elixir
# 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"))
    ...
```

### Callback controller

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

```elixir
# 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
```