Twitter auth with Rails api + Nextjs

engineering

While working on a recent project, I found it very difficult to find guides about how to hook up a Rails API to Nextjs (or create-react-app for that matter). Initially, I couldn't figure out how to get authentication to work on the API side, then be passed on to the client. This guide goes over it.

What this guide will cover:

What it will not cover:

Let's dive in!

Setting up Rails API with Devise, Devise-jwt and Omniauth

We will use Devise as the authentication library. Devise comes with many powerful features that make it very easy to manage authentication across your app, whether through email/password or other methods such as social.

Secondly, we will use Devise-jwt library for managing our JWT authentication that's sitting on top of Devise. The two allow us to take advantage of JWT to manage authentication from an API perspective, and to easily store a token on the client. It's also universal, so we can have our API easily power web and mobile.

Code needed to get the API side working for the client

Your Gemfile should include these:

gem "rack-cors"
gem "devise"
gem "devise-jwt", "~> 0.6.0"

gem "omniauth"
gem "omniauth-twitter"
gem "omniauth-google-oauth2"

Steps:

config.omniauth :twitter, Rails.application.credentials.dig(:twitter, :api_key), Rails.application.credentials.dig(:twitter, :api_secret)
config.omniauth :google_oauth2, Rails.application.credentials.dig(:google, :api_key), Rails.application.credentials.dig(:google, :api_secret)

config.jwt do |jwt|
  jwt.secret = Rails.application.credentials.dig(:jwt_key)
  jwt.dispatch_requests = [
      ["POST", %r{^/login$}],
      ["GET", %r{^/auth/twitter/callback$}],
      ["GET", %r{^/auth/google_oauth2/callback$}]
    ]
  jwt.revocation_requests = [
    ["DELETE", %r{^/logout$}]
  ]
  jwt.expiration_time = 2.weeks.to_i
end
devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :trackable, :omniauthable,
         :jwt_authenticatable, jwt_revocation_strategy: self, omniauth_providers: %i[twitter google_oauth2]
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
  def passthru
  end

  def twitter
    resource = User.from_omniauth(request.env["omniauth.auth"], request.env["omniauth.params"].dig("user_id"))

    sign_in(resource_name, resource)
    # Redirect back to client after successful authentication!
    redirect_to "#{redirect_url}/auth?jwt=#{request.env["warden-jwt_auth.token"]}"
  end

  def google_oauth2
    resource = User.from_omniauth(request.env["omniauth.auth"], request.env["omniauth.params"].dig("user_id"))

    sign_in(resource_name, resource)
    # Redirect back to client after successful authentication!
    redirect_to "#{redirect_url}/auth?jwt=#{request.env["warden-jwt_auth.token"]}"
  end

  private
    def redirect_url
      # will pick up source_url if specified in the initial /auth/twitter request. If not set, fall back to defaults.
      request.env["omniauth.params"].dig("source_url") || (
        Rails.env.production? ? "https://YOUR_WEBSITE_HERE.com" : "http://localhost:8000")
    end
end
devise_for :users,
  path: "",
  path_names: {
    sign_in: "login",
    sign_out: "logout",
    registration: "signup"
  },
  controllers: {
    sessions: "sessions",
    registrations: "registrations",
    omniauth_callbacks: "omniauth_callbacks"
  }
config.session_store :cookie_store, key: "_shepherd_session"
config.middleware.use ActionDispatch::Cookies # Required for all session management
config.middleware.use ActionDispatch::Session::CookieStore, config.session_options

What we have setup will do the following:

Code needed on the client to start the auth process and save the JWT token

import React, { ReactNode } from 'react';
import queryString from 'query-string';
import Button from './dls/Button';
import { Twitter } from '@styled-icons/boxicons-logos/Twitter';
import { authenticate } from '../utils/authentication';
import { API_URL } from '../constants';
import { useRouter } from 'next/router';
import useToasts from '../hooks/useToasts';

type TwitterAuthButtonProps = {
  block?: boolean;
  userId?: number;
  children?: ReactNode;
};

const TwitterAuthButton = ({
  block,
  userId,
  children = 'Sign in with Twitter',
}: TwitterAuthButtonProps) => {
  const { push } = useRouter();
  const { addSuccessToast } = useToasts();

  const handleAuth = () => {
    const q = queryString.stringify({
      source_url: window.location.origin,
      user_id: userId,
    });

    authenticate({
      provider: 'twitter',
      url: `${API_URL}/auth/twitter?${q}`,
      cb: () => {
        addSuccessToast('Logged in successfully');
        push('/');
      },
    });
  };

  return (
    <Button onClick={handleAuth} block={block}>
      <Twitter size={20} /> {children}
    </Button>
  );
};

export default TwitterAuthButton;
export const authenticate = ({
  provider,
  url,
  tab = false,
  cb,
}: AuthenticateArg) => {
  let name = tab ? '_blank' : provider;
  openPopup(provider, url, name);

  function receiveMessage(event) {
    // Do we trust the sender of this message?  (might be
    // different from what we originally opened, for example).
    if (event.origin !== window.location.origin) {
      return;
    }

    if (event.data.jwt && event.data.success) {
      cb();
    }
  }

  window.addEventListener('message', receiveMessage, false);
};
/* istanbul ignore next */
var settings =
  'scrollbars=no,toolbar=no,location=no,titlebar=no,directories=no,status=no,menubar=no';

/* istanbul ignore next */
function getPopupOffset({ width, height }) {
  var wLeft = window.screenLeft ? window.screenLeft : window.screenX;
  var wTop = window.screenTop ? window.screenTop : window.screenY;

  var left = wLeft + window.innerWidth / 2 - width / 2;
  var top = wTop + window.innerHeight / 2 - height / 2;

  return { top, left };
}

/* istanbul ignore next */
function getPopupSize(provider) {
  switch (provider) {
    case 'facebook':
      return { width: 580, height: 400 };

    case 'google':
      return { width: 452, height: 633 };

    case 'github':
      return { width: 1020, height: 618 };

    case 'linkedin':
      return { width: 527, height: 582 };

    case 'twitter':
      return { width: 495, height: 645 };

    case 'live':
      return { width: 500, height: 560 };

    case 'yahoo':
      return { width: 559, height: 519 };

    default:
      return { width: 1020, height: 618 };
  }
}

/* istanbul ignore next */
function getPopupDimensions(provider) {
  let { width, height } = getPopupSize(provider);
  let { top, left } = getPopupOffset({ width, height });

  return `width=${width},height=${height},top=${top},left=${left}`;
}

/* istanbul ignore next */
export default function openPopup(provider, url, name) {
  return window.open(url, name, `${settings},${getPopupDimensions(provider)}`);
}
import React, { useEffect } from 'react';
import { useRouter } from 'next/router';
import { useCookies } from 'react-cookie';
import queryString from 'query-string';
import { Title1 } from '../components/dls/Title';

const Auth = () => {
  const router = useRouter();
  const [, setCookie] = useCookies();
  const {
    query: { jwt },
  } = queryString.parseUrl(router.asPath);

  useEffect(() => {
    if (jwt) {
      setCookie('jwt', jwt);
      window.opener.postMessage(
        {
          jwt,
          success: true,
        },
        '*'
      );
      window.close();
    }
  }, []);

  return (
    <div>
      {jwt ? (
        <Title1>Loading...</Title1>
      ) : (
        <Title1>Authentication failed</Title1>
      )}
    </div>
  );
};

export default Auth;

Let's break this down and how this comes together:

Would love your feedback on this post and future posts

Did this work for you? Let me know on Twitter at @mmahalwy. If it did not for some reason, also let me know. We can debug it together and make sure you're up and running.


If you enjoyed this post, feel free to follow me on Twitter or email where you can stay up to date on upcoming content and life updates