In this blog post, I will walk you through the process of integrating Nostr login functionality into my Rails application using the Nos2x extension. This involved modifying the Devise User model, adding a new route to handle Nostr users, and integrating JavaScript to enhance the login experience.

Overview

Nostr is a decentralized protocol that allows users to authenticate without relying on traditional email/password combinations. By leveraging the Nos2x extension, I was able to implement Nostr login seamlessly. Here’s how I did it.

Step 1: Adding Asynchronous Nostr client in Ruby

bundle add nostr

Step 2: Modifying the Devise User Model

The first step was to modify the Devise User model to allow empty email validation. This is crucial because Nostr users may not have an email address. I updated the User model as follows:

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable, :registerable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable # Removed :validatable

  # Validations
  validates :nostr_public_key, presence: true, uniqueness: true, allow_blank: true
  validates :email, uniqueness: true, allow_blank: true # Make email optional
  validates :password, length: { minimum: 6 }, allow_blank: true # Make password optional but enforce length if provided

  # Conditional validation for email and password
  validates :email, presence: true, unless: :nostr_public_key_present?
  validates :password, presence: true, unless: :nostr_public_key_present?

  private

  def nostr_public_key_present?
    nostr_public_key.present?
  end
end

By adding this validation, I ensured that users logging in via Nostr would not be required to provide an email address.

Step 3: Adding a New Route for Nostr Users

Next, I needed to create a new route to handle the login process for Nostr users. I added the following route to my config/routes.rb file:

Rails.application.routes.draw do
  # Other existing routes...

  post 'users/nostr_login', to: 'users#nostr_login', as: 'nostr_login'
end

This route points to a new action in the UsersController that will manage the authentication process for Nostr users.

Step 4: Implementing the Nostr Login Action

In the UsersController, I implemented the nostr_signup action to handle the login logic:

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  # Custom error handling for CSRF token verification failure
  rescue_from ActionController::InvalidAuthenticityToken do |exception|
    render json: { error: "Can't verify CSRF token authenticity." }, status: :unprocessable_entity
  end

  def nostr_signup
    nostr_signed_event = params[:signed_event]

    if not nostr_signed_event
      render json: { success: false, error: "No signed event provided." }, status: :unprocessable_entity
      return
    end

    public_key = nostr_signed_event[:pubkey]
    content = nostr_signed_event[:content]
    kind = nostr_signed_event[:kind]
    csrf_token = request.headers['X-CSRF-Token']
    tags_hash = tags.to_h
    challenge_value = tags_hash["challenge"]
    relay_value = tags_hash["relay"]
    expected_relay_value = ENV.fetch('NOSTR_AUTH_RELAY_URL', 'wss://relay.primal.net')

    # Check if the challenge_value and relay_value are matching or if kind is valid
    if challenge_value != csrf_token || kind != 22242 || relay_value != expected_relay_value
      return render json: { success: false, error: "Invalid event provided." }, status: :unprocessable_entity
    end

    nostr_event = Nostr::Event.new(
      content: content,
      id: nostr_signed_event[:id],
      created_at: nostr_signed_event[:created_at],
      kind: kind,
      pubkey: public_key,
      sig: nostr_signed_event[:sig],
      tags: nostr_signed_event[:tags]
    )

    # Validate the event's signature
    unless nostr_event.verify_signature
      render json: { success: false, error: "Invalid event signature." }, status: :unprocessable_entity
      return
    end

    user = User.find_or_initialize_by(nostr_public_key: public_key)

    ...
  end

  private

  def nostr_signup_params
    params.permit(signed_event: [:id, :kind, :content, :tags, :created_at, :pubkey, :sig])
  end
end

This action will handle the authentication process and create a new user if they do not already exist.

Step 5: Integrating JavaScript for Nostr Login

To enhance the user experience, I added a link for Nostr login on the Devise login page. I included the following JavaScript code to handle the Nostr login process:

document.addEventListener("turbo:load", function () {
  if (isNostrAvailable() && !document.getElementById('nostr-signup')) {
    createNostrSignupButton();
  }
});

// Function to check if Nostr is available
function isNostrAvailable() {
  return window.nostr && typeof window.nostr.getPublicKey === 'function';
}

// Function to create and append the Nostr signup button
function createNostrSignupButton() {
  event.preventDefault();
  const button = document.createElement('button');
  button.id = "nostr-signup";
  button.onclick = function(event) {
    event.preventDefault();
    nostrSignupHandler();
  };
  button.type = "submit";
  button.textContent = "<%= t('nostr.with_extension') %>";

  const actionsDiv = document.querySelector('.actions');
  if (actionsDiv) {
    actionsDiv.appendChild(button);
  }
}

async function nostrSignupHandler() {
  try {
    const publicKey = await window.nostr.getPublicKey();

    if (!publicKey) {
      alert("Sign-up failed. No public key.");
    }

    token = document.querySelector('meta[name="csrf-token"]').getAttribute("content");

    const event = {
      "created_at": Math.floor(Date.now() / 1000),
      "content": "",
      "tags": [
        [
          "relay",
          "<%= ENV['NOSTR_AUTH_RELAY_URL'] || 'wss://relay.primal.net' %>"
        ],
        [
          "challenge",
          token
        ]
      ],
      "kind": 22242,
      "pubkey": publicKey
    };

    // Sign the event
    const signedEvent = await window.nostr.signEvent(event);

    if (!signedEvent) {
      alert("Sign-up failed. Event not signed.");
    }

    const response = await fetch("/nostr_signup", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-CSRF-Token": token,
      },
      body: JSON.stringify({ "signed_event": signedEvent }),
    });
    ...

Conclusion

By following these steps, I successfully integrated Nostr login functionality into my Rails application using the Nos2x extension. This not only enhances the user experience by providing a decentralized login option but also aligns with modern authentication practices.

Feel free to reach out if you have any questions or need further assistance with implementing Nostr login in your own applications!

If you want to see it in action, go ahead to Medical Dictionary - Login Page for example. You will need to be on a desktop browser with a Nostr Extension installed.

Happy coding!