Rails 6 API + React + Google login: how to authorize user to access certain server routes after login?

Viewed 675

Please let me know if you need more information. I'm building a website with a React frontend and Rails 6, which is in api-only mode. I've added Google login to the frontend using the react-google-login component. Once a user has logged in on the frontend, I send the ID token provided by Google to the server, which validates it using the google-id-token gem. This all works fine, but afterwards, I'd like the user to be able to post to certain routes on the server. From googling, I've found a few different solutions, but they all seem to involve sending cookies back and forth.

  1. Using Rails session, store user ID. If configuration is set up correctly, this is sent to the frontend as an HttpOnly cookie, which (as long as credentials: true) sends back the cookie in each following get/post request.

  2. Using Rails cookies, store user ID or JWT as an HttpOnly cookie. Seems basically the same as above but with extra work?

  3. Using Authorize header and JWT. Not 100% clear on how this works - how does Rails get the JWT to the frontend - also cookies?

I've tried all 3 to varying extents, but spent the most time on the first method. However, I can't seem to get Rails to send the session[:token] (that I set after user authentication using Google's ID token) to the frontend. I'm open to switching to another method or one that I haven't listed. Here is what my code looks like.

# application.rb
module MyBackendApi
  class Application < Rails::Application
    # ... other configs
    config.middleware.use ActionDispatch::Cookies
    config.middleware.use ActionDispatch::Session::CookieStore


    config.middleware.insert_before 0, Rack::Cors do
      allow do
        origins 'https://my-frontend-app.netlify.app'
        resource '*', 
          credentials: true,
          headers: :any, 
          methods: [:get, :post]
      end
    end

    config.hosts << "my-backend-api.herokuapp.com"
    config.hosts << "localhost"
  end
end

I also have include ::ActionController::Cookies in ApplicationController.

Below is how I authenticate the user using google-id-token and set the session hash.

class UsersController < ApplicationController
  def signin
    validator = GoogleIDToken::Validator.new
    begin
      payload = validator.check(user_params[:idtoken], ENV['GOOGLE_CLIENT_ID'])
      user_id = payload['sub']
      user = User.find_by(user_id: user_id)
      if user.nil?
        user = User.create({
          user_id: user_id
        })
      end
      session[:user_id] = user_id
      render json: {status: :ok}
    rescue GoogleIDToken::ValidationError => e
      puts "Cannot validate: #{e}"
      render json: {message: 'Bad Request', reason: 'Error validating Google token ID'}, status: :bad_request
    end
  end

Here is how I check whether a user is logged in or not. The method logged_in_user is used as a before_action method for the appropriate controller actions.

  def current_user
    user_id = session[:user_id]
    if !user_id.nil?
      user ||= User.find_by(user_id: user_id)
    end
  end

  def logged_in?
    !current_user.nil?
  end

  def logged_in_user
    unless logged_in?
      render json: {message: 'Not logged in'}, status: :forbidden
    end
  end

Now for my frontend code. Below is how I authenticate the user (taken almost directly from the Google docs). Note that /googlesignin routes to users#signin in the backend API.

    var xhr = new XMLHttpRequest();
    xhr.open('POST', 'https://my-backend-api.herokuapp.com/googlesignin');
    xhr.withCredentials = true;
    xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
    xhr.send('idtoken=' + idtoken);

Then, I try to post to a /submit route in the backend API.

      let headers = new Headers({
        'Content-Type': 'application/json'
      });
      fetch('https://my-backend-api.herokuapp.com/submit', {
        method: 'POST',
        mode: 'cors',
        headers: headers,
        cache: 'no-cache',
        redirect: 'follow',
        referrer: 'no-referrer',
        credentials: 'include',
        body: JSON.stringify(this.state.selected),
      })
      .then(function (response) {
        let data = response.json()
        console.log(data)
        return data;
      })

I also tried the following, with axios:

      axios.post('https://my-backend-api.herokuapp.com/submit', {
        submission: this.state.selected,
      }, {
        withCredentials: true,
      })
        .then(response => {
          console.log(response)
        })
        .catch(error => {
          console.log(error)
        })

As I'm very new to cookies, I'm not sure whether I should be able to see the session[:user_id] cookie in Chrome devtools (application tab) or not. I don't see it, so I assume that the frontend is either not accepting or not receiving the cookie from the backend. I'm not sure though.

Any help is super appreciated, I've been struggling with this issue for a while, with no idea what I could be doing wrong. I have been finding user auth to be a little overwhelming thus far. Again, I'm willing to switch to another method. Please let me know if I should post more information. Thank you!

Update: One thing that works is to manually set a JWT Access-Token header on Rails, and on the frontend, take that header and put it into a cookie. Then when I send later requests, I stick the cookie into an Authorization header, which I check for and validate in Rails. But, is this safe? Intuitively, it doesn't seem secure?

1 Answers

I don't think you need to use sessions at all, as that is the main idea by using Rails in API mode.

I remember I did something similar like that a few years ago(having the API(Rails API without devise) in one subdomain and the fronted(Backbone.js) in a separate one), in this repository

  • How it looks the current_user based on a token included on each request(This token was generated as a result of the Google authentication interaction)
https://github.com/heridev/rails_public_api/blob/master/app/controllers/application_controller.rb#L18
  • How do we create the initial user's record for later searching and authentication purposes:
https://github.com/heridev/rails_public_api/blob/master/app/controllers/api/sessions_controller.rb#L4

Here is another gist you can take as a reference

In general, the workflow looks like this:

  • The frontend calculates the Google URL and allows the user to log in on the G-suite.
  • Then a token is returned from Google and received as a query string in the Single Page App of your preference.
  • On that route onLoad, it uses that token to make an HTTP request to the backend API in a separate subdomain.
  • In the backend API, we validate the Google token(by making a second HTTP request to Google most likely) and if valid it searches/creates the user and saves that token in the right record.
  • The API endpoint returns the user details(google_token, user_email, etc).
  • The frontend saves that data in localStorage
  • On every subsequent request to the API, the frontend includes the google_token, so we can search the actual user by that Google token.
  • To log out the current_user in the backend it is just a matter of making the User.google_token field as nil

I'm planning to work on a similar application, but with a more recent Stack(React 17 and Rails 6), if this gets more attention, I can come back in a few and share more details on how that ended up.

Last words: No no need for JWT or any devise stuff(devise_token_auth) unless you need to support also a user/password authentication strategy.

Related