June 28, 2020 • 7 min read
CODE BITESWhile 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!
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.
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:
bundle exec rake secret
and store it in your credentialsconfig/initializers/devise.rb
file, add the following. We are setting the paths that will be picked up by devise-JWT for logging in, logging out and omniauth callbacks. Only these paths will allow JWT to be generated and accessed.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
app/models/user.rb
file should include this. This includes 2 important features omniauthable
for omniauth support and :jwt_authenticatable, jwt_revocation_strategy: self
for JWT authentication. There are many revocation strategies listed here but we decided to go with JTIMatcher.devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable,
:trackable, :omniauthable,
:jwt_authenticatable, jwt_revocation_strategy: self, omniauth_providers: %i[twitter google_oauth2]
app/controllers/omniauth_callbacks_controller.rb
. This controller's actions will be called on the omniauth callback.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"
}
application.rb
file: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:
/auth/twitter
, the request will be picked up with omniauth and start the oauth dance/auth/twitter/callback
which is then picked up by our OmniauthCallbacksController
TwitterAuthButton
file: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_imageUrl: window.location.origin,
user_id: userId,
});
authenticate({
provider: 'twitter',
imageUrl: `${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:
/auth/twitter
path/auth?jwt=THE_JWT_TOKEN
pathpostMessage
to the opener window (where the user clicked the button initially) and close the popup windowpush
) the user to the homepage and fire off a success toastDid 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