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"}])
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
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
! 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
Router Pipeline
pipeline :google_auth do
plug :put_csp
plug :put_secure_browser_headers, %{"referrer-policy" => "no-referrer-when-downgrade"}
plug MyAppWeb.PlugGoogleAuth
end
Use it:
scope "/", MyApp do
pipe_through: [:google_auth]
post "/google_auth", OneTapController, :login
end
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>
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
Check Google's code generator.
Config
# config/config.exs
config :my_app,
google_callback_uri: "/google_auth",
# 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.
"""),
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
Top comments (2)
How did you get around Phoenix complaining invalid CSRF?
you need to use a different pipeline without
plug :protect_from_forgeryand implement your own CSRF token verification:references: