Google OAuth on LiveView?

I’m trying to place one of those “Sign in with Google” buttons. The it has 2 APIs.

When I try HTML API, the button gets rendered then disappear as soon as LiveSocket connects.

If I do JS API, now I can hook the button on mounted. But I can’t reload the page autumatically so I can’t update the state. I have to manually refresh to see status updated.
.
If I use redirect UX mode with JS API, I can’t pass CSRF token from the Google to my page.

What could be a solution or a work-around?

Have you tried setting phx-update to ignore?

A container can be marked with phx-update, allowing the DOM patch operations to avoid updating or removing portions of the LiveView, or to append or prepend the updates rather than replacing the existing contents. This is useful for client-side interop with existing libraries that do their own DOM operations. The following phx-update values are supported:

  • ignore - ignores updates to the DOM regardless of new content changes

source: DOM patching & temporary assigns — Phoenix LiveView v0.20.2

1 Like

Thank you, now I can use HTML API.

But Phoenix says invalid CSRF (Cross Site Request Forgery) token.

Google gives me separate g_csrf_token. Can I integrate it to Phoenix?

There’s more than one way of dealing with this. Here’s some resources to get a better idea of what Plug is doing with respect to CSRF, why that error is happening, and how to proceed.

The Plug.CSRFProtection is enabled in your router with protect_from_forgery. This is set by default in the browser pipeline. Once a plug has been added, there is no way to disable it, instead it has to be not set in the first place. You can do this by moving it out of browser and only including it when it is required.
source: elixir - How to selectively disable CSRF check in Phoenix framework - Stack Overflow

You can pass the :allow_hosts option to control any host that you may want to allow. The values in :allow_hosts may either be a full host name or a host suffix. For example: ["www.example.com", ".subdomain.example.com"] will allow the exact host of "www.example.com" and any host that ends with ".subdomain.example.com".
source: Plug.CSRFProtection — Plug v1.15.2

Hope this helps and looking forward to seeing your solution!

1 Like

Thank you, I couldn’t find a way to incorporate Phoernix CSRF to Google API.

The problem is that regardless to Plug.CSRFProtection, Google API requests with its own set of cookies and not send any Phoenix stuff.

So I guess the only way is to turn off CSRF in Phoenix pipeline.

Hmm, to clarify the role of g_csrf_token, it’s what google’s oauth returns to your phoenix server when it hits the callback url/endpoint you’ve set to prove it’s from them, right?

Off the top of my head, the two approaches I alluded to above would be either adding the google host that calls out to your callback url/endpoint or creating a separate scope for that endpoint that doesn’t pipe through the protect_from_forgery plug and instead makes sure the g_csrf_token is legitimate.

For the latter approach, the stackoverflow answer linked above and this blog post should point the way.

1 Like

Thank you!

Actually, I have already replied to the same post asking how they dealt with CSRF tokens :joy: I’ve already did the second approach, but I would love to take the first approach. It’s more proactive and clean.

But when I see the failing request, host is localhost and nothing google is included. Can I still add the Google host and somehow pass Phoenix CSRF token around?

This is failing request.

POSThttp://localhost:4000/access
[HTTP/1.1 403 Forbidden 90ms]

	
POST
	http://localhost:4000/access
Status
403
Forbidden
VersionHTTP/1.1
Transferred65.84 kB (65.65 kB size)
Referrer Policystrict-origin-when-cross-origin
Request PriorityHighest

    	
    cache-control
    	max-age=0, private, must-revalidate
    content-length
    	65649
    content-type
    	text/html; charset=utf-8
    date
    	Sat, 28 Jan 2023 03:22:20 GMT
    server
    	Cowboy
    	
    Accept
    	text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
    Accept-Encoding
    	gzip, deflate, br
    Accept-Language
    	en-US,en;q=0.5
    Connection
    	keep-alive
    Content-Length
    	1188
    Content-Type
    	application/x-www-form-urlencoded
    Cookie
    	g_state={"i_l":0}; _session=SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYRVlwTlRZbldtMFY0aDE4SlVGM2w2OUNX.6LR98egMquc_t52ldRGu96GFrzpLZbsAMoIXdTFw1Jo; g_csrf_token=1e50bf2b76d7cdcb
    Host
    	localhost:4000
    Origin
    	http://localhost:4000
    Referer
    	http://localhost:4000/
    Sec-Fetch-Dest
    	document
    Sec-Fetch-Mode
    	navigate
    Sec-Fetch-Site
    	same-origin
    Upgrade-Insecure-Requests
    	1
    User-Agent
    	Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/109.0

That’s odd, can’t be sure what’s going on without more information… if I had to go out on a limb, maybe try setting a public domain callback endpoint with something like ngrok that tunnels to your localhost and see if that makes a difference.

The second approach has a few advantages in my book. For example, your app will be unaffected if google ever changes the host. And it scopes the permission to just one endpoint/route rather than all of them when you change the plug itself. It’s also Elixir-ish in that it’s a bit more explicit and precise if a bit more verbose.

Ah, just noticed your comment on that post. From what I can tell, the way they’re getting around it is the same as the stackoverflow answer – create a separate api pipeline that doesn’t include the protect_from_forgery plug.

pipeline :api do
  plug :accepts, ["json"]

  post("/auth/one_tap", AppWeb.OneTapController, :handler)
end

I also came across this well documented and maintained elixir-auth-google library that seems to be able to implement the google oauth sign in button without any CSRF issues judging by its demo repo. Its router module still pipes the callback route through the protect_from_forgery plug. Maybe take a look and see if they’re doing anything differently?

2 Likes

WOW, I cannot comprehend how I’ve missed the library. Thank you!

UPDATE

The library customizes the request by using the more granular OAuth API, instead of the whole button API. CSRF is in control because now Phoenix completely owns the request.

1 Like

You’re welcome and glad to hear that was helpful!

And it makes sense why the library took that approach – the more granular OAuth2 API that supports both authentication and authorization does seem to be a more natural fit for server side web applications like Phoenix apps.

1 Like

A member was asking for my implementation around LiveView. So I share here. There’s nothing much LiveView though. For the posterity.

I’m using the button API at the moment, not the OAuth API. The button POSTs a JWT to /access. The only LiveView thing here is phx-update="ignore". It prevents LiveSocket connection removing the button.

# button component
def google_oauth(assigns) do
  ~H"""
  <button id="google_oauth" phx-update="ignore">
    <div
      id="g_id_onload"
      data-client_id={OAuth.id(:google)}
      data-login_uri={url(~p"/access")}
      data-auto_prompt="false"
    >
    </div>
    <div class="g_id_signin" data-type="standard" data-size="medium" data-text="continue_with">
    </div>
  </button>
  """
end

To get around the CSRF token, I’ve pipe_through :api them. I check CSRF token from google instead. You can skip this part if you use the OAuth API within Phoenix. The button API requires it because requests are forged in the nested HTML in the button where Phoenix have no control.

# router
pipeline :api do
  plug :accepts, ["json"]
end

scope "/", Feder do
  pipe_through :api

  post "/access", Auth.Conn, :sign_in
  delete "/access", Auth.Conn, :sign_out
end

Process them in a controller of your choice.

# actions
@doc """
Signs account in with JWT.
"""
def sign_in(conn, params) do
  with conn <- fetch_cookies(conn),
       true <- conn.cookies["g_csrf_token"] == params["g_csrf_token"],
       {:ok, %{"email" => email}} <- OAuth.verify(params["credential"]),
       %{token: token} <- Access.grant(email),
       id when is_integer(id) <- Access.get_account_id_by_token(token),
       cookie_name <- Access.token_cookie() |> Keyword.get(:name),
       cookie_opts <- Access.token_cookie() |> Keyword.drop([:name]) do
    conn
    |> put_resp_cookie(cookie_name, token, cookie_opts)
    |> redirect(to: ~p"/")
  end
end

@doc """
Signs the account out. Clears all session data.
"""
def sign_out(conn, _params) do
  with conn <- fetch_session(conn),
       token <- get_session(conn, Access.token_key()),
       socket <- get_session(conn, :live_socket_id) do
    Access.delete_by_token(token)
    Feder.Endpoint.broadcast(socket, "disconnect", %{})

    conn
    |> configure_session(renew: true)
    |> clear_session()
    |> delete_resp_cookie(Access.token_cookie()[:name])
    |> redirect(to: ~p"/")
  end
end
3 Likes