Home

Action Cable Authentication with JSON Web Tokens

August 12, 2023 • 3 min read

#ruby on rails #javascript #websockets

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!