August 12, 2023 • 3 min read
I'm building a messaging application with Rails and React, and I ran into an interesting authentication problem that I thought I would write about in case someone else suffers the same issue. The React project is a single-page application that lives in the same project as the API. It gets served up by a StaticController
that renders the application to index.html.erb
.
// app/javascript/application.tsx
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./components/App";
import { User } from "./types/User";
document.addEventListener("DOMContentLoaded", () => {
const rootEl = document.getElementById("root");
let initialUser: User | null = null;
const pmuser = localStorage.getItem("pmuser");
if (pmuser) {
initialUser = JSON.parse(pmuser);
}
const root = createRoot(rootEl);
root.render(
<React.StrictMode>
<App initialUser={initialUser} />
</React.StrictMode>,
);
});
The application is hydrated with the user currently stored in localStorage
. Originally, because the React application lives in the same project and on the same domain as the API, I was using Devise and session-based authentication. But for the sake of simplicity, I recently migrated away from Devise to has_secure_password
and JSON web tokens. I started by creating a JsonWebToken
module to handle encoding and decoding the tokens.
# lib/json_web_token.rb
require "jwt"
module JsonWebToken
def self.encode(payload, secret, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, secret)
end
def self.decode(token, secret)
decoded = JWT.decode(token, secret).first
HashWithIndifferentAccess.new(decoded)
rescue JWT::DecodeError
nil
end
end
Next, I added a LoginController
to validate users and issue tokens upon authentication. Be sure to add the login
action to config/routes.rb
as well.
# app/controllers/api/v1/login_controller.rb
require "json_web_token"
class Api::V1::LoginController < ApplicationController
skip_before_action :verify_authenticity_token
JWT_SECRET = Rails.application.credentials.jwt_secret
def login
user = User.find_by(email: user_params[:email])
if user&.authenticate(user_params[:password])
token = JsonWebToken.encode({ user_id: user.id }, JWT_SECRET)
render json: { token: token }
else
head :unauthorized
end
end
private
def user_params
params.require(:user).permit(:email, :password)
end
end
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
post "/login", to: "login#login"
end
end
end
Now we can easily log in from the React application to get a token. Unfortunately, authenticating with WebSockets and JSON web tokens is a little more complicated, especially in the context of Rails and Action Cable. Most documentation and tutorials—like this one—only cover authenticating with cookies. Others suggest passing the token in the connection URL. But passing credentials in URLs is generally not a good idea. It's not as big of an issue if you're using a secure protocol like WSS or HTTPS, but it's not ideal.
Instead, I wanted to set up a sort of handshake scenario, where I don't care who connects to the WebSocket server. I only care that whoever receives the messages has a valid JWT. So the Connection
class is pretty simple.
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = nil
end
end
end
After the initial connection, the client sends a subscribe
event along with the JWT to validate the user. I did this with vanilla JavaScript and the browser's WebSocket API, but you can use Action Cable's JavaScript client library.
// app/javascript/components/Login.tsx
const [user, setUser] = useContext(UserContext);
const socket = useRef(new WebSocket("ws://localhost:3000/cable"));
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
const ws = socket.current;
const handleConnect = () => {
ws.send(
JSON.stringify({
command: "subscribe",
identifier: JSON.stringify({
channel: "RoomChannel",
identifier: "room",
token: user?.token,
}),
}),
);
};
const handleReceiveMessage = ({ data }) => {
const parsed = JSON.parse(data);
if (parsed.type !== "ping" && parsed.message) {
const { message } = parsed.message;
setMessages([...messages, message]);
}
};
ws.addEventListener("open", handleConnect);
ws.addEventListener("message", handleReceiveMessage);
return () => {
ws.removeEventListener("open", handleConnect);
ws.removeEventListener("message", handleReceiveMessage);
};
}, [user, messages]);
Finally, we can retrieve the token in the Channel
class and use it to validate the current connection. If the JWT is invalid, we can reject the connection, ensuring that only authenticated users receive messages from our application.
# app/channels/room_channel.rb
require "json_web_token"
class RoomChannel < ApplicationCable::Channel
JWT_SECRET = Rails.application.credentials.jwt_secret
def subscribed
token = params[:token]
if token
decoded = JsonWebToken.decode(token, JWT_SECRET)
if decoded.is_a?(Hash) && decoded[:exp].to_i > Time.now.to_i
connection.current_user = User.find(decoded[:user_id])
stream_from "room"
return
end
end
reject
end
def unsubscribed
end
end
After rejecting the connection from Action Cable, the client will receive a message that the connection was rejected, which we can use to redirect the user to the login page. If you're interested in learning more about authentication and WebSockets, Heroku wrote an interesting post here.
They talk about a system that exchanges a JWT for a ticket that can be used to connect to the WebSocket server. From what I understand, if you pass that in the URL, you're still at risk of someone being able to read your messages from the WebSocket server. But at least they won't be able to use your JWT to perform any other actions on the server.
I hope this short tutorial was helpful in some way, and as always, if you have any questions or suggestions, feel free to reach out!