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 defaultFinch
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"}])
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.
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
! 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
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
and use it to parse Googles' response:
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.
<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, cfSobelow
)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
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"))
...
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
Top comments (2)
How did you get around Phoenix complaining invalid CSRF?
you need to use a different pipeline without
plug :protect_from_forgery
and implement your own CSRF token verification:references: