A quick write on how to use Google's One tap login with Elixir. This gives an authentication tool in minutes.
There is no Elixir library to verify the JWT sent by Google so the code below could be a base to the missing Elixir library for Google One tap. This is largely inspired by this answer. It is based on the Jose/Joken library.
This module exposes one function which takes the conn
, the jwt
token and the anti-CSRF token received from Google.
ElixirGoogleCerts.verified_identity(conn, jwt, g_csrf_token)
See the relevant doc.
This module uses Joken
(and Jason
and HTTPoison
or equivalent).
defmodule ElixirGoogleCerts do
@g_certs3_url "https://www.googleapis.com/oauth2/v3/certs"
@iss "https://accounts.google.com"
def verified_identity(conn, jwt, g_csrf_token) do
with :ok <- double_token_check(conn, g_csrf_token),
{:ok,
%{
"aud" => aud,
"azp" => azp,
"email" => email,
"iss" => iss,
"name" => name,
"picture" => pic,
"given_name" => given_name,
"sub" => sub
}} <- check_identity(jwt),
true <- check_user(aud, azp),
true <- check_iss(iss) do
{:ok, %{email: email, name: name, google_id: sub, picture: pic, given_name: given_name}}
else
{:error, msg} -> {:error, msg}
false -> {:error, :wrong_check}
end
end
defp check_identity(jwt) do
case Joken.peek_header(jwt) do
{:error, msg} ->
{:error, msg}
{:ok, %{"kid" => kid, "alg" => alg}} ->
%{"keys" => certs} =
@g_certs3_url
|> HTTPoison.get!()
|> Map.get(:body)
|> Jason.decode!()
cert = Enum.find(certs, fn cert -> cert["kid"] == kid end)
signer = Joken.Signer.create(alg, cert)
Joken.verify(jwt, signer, [])
end
end
# token in body is equal to received cookie
defp double_token_check(conn, g_csrf_token) do
case conn.cookies do
%{"g_csrf_token" => g_cookie} ->
if g_cookie == g_csrf_token,
do: :ok,
else: {:error, "Failed to verify double submit cookie."}
_ ->
{:error, "No cookie"}
end
end
# ---- Google post-checking recommendations
defp check_user(aud, azp) do
aud == aud() || azp == aud()
end
defp check_iss(iss), do: iss == @iss
defp aud, do: System.get_env("GOOGLE_CLIENT_ID")
end
We firstly check that the anti-CSRF token received in the response body and in the cookie header are equal.
Then, on each call, we retrieve Google's certs (JWK in our case).
Then we use this "cert" (more precisely one out of the two sent) to certify the received JWT. Then we can extract the data and perform the extra checks as per Google's recommendations.
this may not be optimal as we have to reach Google for the certs on each call. This can be optimised by saving the certs and only relying on the Cache-Control to renew it. This package can be useful for this purpose.
Example
To use One tap, you firstly need to setup an app in the Google console and get credentials. You also have to enter a URI for Google to post you back a response.
Then add this HTML snippet that uses Google's SDK:
<script src="https://accounts.google.com/gsi/client" async defer></script>
<div id="g_id_onload"
data-client_id={System.get_env("GOOGLE_CLIENT_ID")}
data-login_uri="http://localhost:4000/auth/one_tap"
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"
data-width="400">
</div>
The login endpoint you defined while registering your app is set above in the dataset login_uri
. You can also use Javascript to fill with the context:
const oneTap = document.querySelector("#g_id_onload");
if (oneTap) {
oneTap.dataset.login_uri = window.location.href + "/auth/one_tap";
}
Then declare your endpoint in your router and define a callback in a controller:
pipeline :api do
plug :accepts, ["json"]
post("/auth/one_tap", AppWeb.OneTapController, :handler)
end
The controller will digest the payload with the ElixirGoogeCerts.verified_identity/3
function above.
Below is an example:
# one_tap_controller.ex
action_fallback LoginErrorController
def handler(conn, %{"credential" => jwt, "g_csrf_token" => g_csrf_token}) do
with {:ok, profile} <-
App.ElixirGoogleCerts.verified_identity(conn, jwt, g_csrf_token) do
conn
|> fetch_session()
|> put_session(:profile, profile)
|> redirect(to: Routes.welcome_path(conn, :index))
end
end
where the endpoint "/welcome" comes with controller/view/template.
Top comments (1)
How did you get around Phoenix complaining invalid CSRF?