solrengine-auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6622627cd1bbb261117bfab5b6a746b51c511885aae27f43f9246a9985d861d7
4
+ data.tar.gz: 6cdab9cc1a1b92d0a4dff46b2119d90ab76a78e2fdae6181ffb3d34eaa82ab0d
5
+ SHA512:
6
+ metadata.gz: 9a90484823d798a4aadc0c448a79bc99094b26ed85f4c24ab71aa301a8f31461cfb020de3c3bf6f7751bfe386a1b0e66e38f370e4980d37c651bd52f4a8b6ce5
7
+ data.tar.gz: 0f994647ae7a0a85ac5b8c7ab459c95a25fe23ed2fd47a564544d6de4d0a02ebae3627bb0683398aba1f4c10604312b4fb4b692ff6022ec2d68eb36215cf9a92
data/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # SolRengine Auth
2
+
3
+ Solana wallet authentication for Ruby on Rails. Sign in with any Wallet Standard compatible wallet (Phantom, Solflare, Backpack, Jupiter, etc.) using SIWS (Sign In With Solana).
4
+
5
+ Part of the [SolRengine](https://github.com/solrengine) framework.
6
+
7
+ ## Install
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "solrengine-auth"
13
+ ```
14
+
15
+ Run the generator:
16
+
17
+ ```bash
18
+ rails generate solrengine:auth:install
19
+ rails db:migrate
20
+ ```
21
+
22
+ This adds:
23
+ - `wallet_address`, `nonce`, `nonce_expires_at` columns to your User model
24
+ - `Authenticatable` concern included in User
25
+ - `ControllerHelpers` concern included in ApplicationController
26
+ - Routes mounted at `/auth` (login, nonce, verify, logout)
27
+ - Configuration initializer
28
+
29
+ ## Setup
30
+
31
+ Install the JavaScript dependencies:
32
+
33
+ ```bash
34
+ yarn add @wallet-standard/app @solana/wallet-standard-features
35
+ ```
36
+
37
+ Register the wallet Stimulus controller in your `app/javascript/controllers/index.js`:
38
+
39
+ ```js
40
+ import WalletController from "solrengine/auth/wallet_controller"
41
+ application.register("wallet", WalletController)
42
+ ```
43
+
44
+ ## Configuration
45
+
46
+ ```ruby
47
+ # config/initializers/solrengine_auth.rb
48
+ Solrengine::Auth.configure do |config|
49
+ config.domain = ENV.fetch("APP_DOMAIN", "localhost")
50
+ config.nonce_ttl = 5.minutes
51
+ config.after_sign_out_path = "/"
52
+
53
+ # The model class used for wallet authentication (String or Class).
54
+ # config.user_class = "User"
55
+
56
+ # Chain ID shown in the wallet sign-in message.
57
+ # Defaults to ENV["SOLANA_NETWORK"] or "mainnet".
58
+ # config.chain_id = "devnet"
59
+ end
60
+ ```
61
+
62
+ ## How It Works
63
+
64
+ 1. User clicks "Connect Wallet" -- Stimulus discovers installed wallets via Wallet Standard
65
+ 2. User selects a wallet -- extension popup opens
66
+ 3. Rails generates a SIWS message with a nonce (POST `/auth/nonce`)
67
+ 4. Wallet signs the message (Ed25519)
68
+ 5. Rails verifies the signature, validates the nonce, and creates a session (POST `/auth/verify`)
69
+
70
+ No passwords. No emails. The wallet is the identity.
71
+
72
+ ## Usage in Controllers
73
+
74
+ ```ruby
75
+ class DashboardController < ApplicationController
76
+ before_action :authenticate!
77
+
78
+ def show
79
+ @wallet_address = current_user.wallet_address
80
+ end
81
+ end
82
+ ```
83
+
84
+ `current_user`, `logged_in?`, and `authenticate!` are provided by the `Solrengine::Auth::Concerns::ControllerHelpers` concern, which the generator includes in your ApplicationController.
85
+
86
+ ## Standalone Usage
87
+
88
+ The verifier can be used without Rails:
89
+
90
+ ```ruby
91
+ require "solrengine/auth"
92
+
93
+ verifier = Solrengine::Auth::SiwsVerifier.new(
94
+ wallet_address: "Abc...xyz",
95
+ message: siws_message,
96
+ signature: signature_bytes,
97
+ domain: "myapp.com"
98
+ )
99
+
100
+ verifier.verify # => true/false
101
+ verifier.verify! # => true or raises VerificationError
102
+ ```
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,262 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { getWallets } from "@wallet-standard/app"
3
+ import { SolanaSignMessage } from "@solana/wallet-standard-features"
4
+
5
+ // Wallet connection + SIWS authentication controller.
6
+ //
7
+ // Discovers wallets via Wallet Standard. Uses legacy provider for connect
8
+ // (preserves user gesture for popup), wallet-standard for signMessage.
9
+ export default class extends Controller {
10
+ static targets = ["connectBtn", "signing", "status", "walletList"]
11
+ static values = {
12
+ nonceUrl: String,
13
+ verifyUrl: String,
14
+ dashboardUrl: String
15
+ }
16
+
17
+ connect() {
18
+ this.availableWallets = []
19
+ this.selectedWallet = null
20
+ this.discoverWallets()
21
+ }
22
+
23
+ discoverWallets() {
24
+ const { get, on } = getWallets()
25
+
26
+ this.addWallets(get())
27
+
28
+ on("register", (...newWallets) => {
29
+ this.addWallets(newWallets)
30
+ })
31
+ }
32
+
33
+ addWallets(wallets) {
34
+ for (const wallet of wallets) {
35
+ if (wallet.features[SolanaSignMessage]) {
36
+ if (!this.availableWallets.find(w => w.name === wallet.name)) {
37
+ this.availableWallets.push(wallet)
38
+ }
39
+ }
40
+ }
41
+
42
+ if (this.availableWallets.length > 0 && !this.selectedWallet) {
43
+ this.selectedWallet = this.availableWallets[0]
44
+ }
45
+
46
+ this.renderWalletList()
47
+ }
48
+
49
+ renderWalletList() {
50
+ if (!this.hasWalletListTarget) return
51
+ if (this.availableWallets.length === 0) return
52
+
53
+ this.walletListTarget.replaceChildren()
54
+
55
+ this.availableWallets.forEach((wallet, index) => {
56
+ const button = document.createElement("button")
57
+ button.dataset.action = "click->wallet#selectWallet"
58
+ button.dataset.walletIndex = index
59
+ button.className = `flex items-center gap-3 w-full p-3 rounded-xl border cursor-pointer transition-all duration-200 ${
60
+ this.selectedWallet === wallet
61
+ ? 'border-purple-500 bg-purple-900/20'
62
+ : 'border-gray-700 hover:border-gray-500 bg-gray-800/30'
63
+ }`
64
+
65
+ if (wallet.icon && /^(https?:|data:image\/)/.test(wallet.icon)) {
66
+ const img = document.createElement("img")
67
+ img.src = wallet.icon
68
+ img.alt = wallet.name
69
+ img.className = "w-8 h-8 rounded-lg"
70
+ button.appendChild(img)
71
+ }
72
+
73
+ const span = document.createElement("span")
74
+ span.className = "text-white font-medium"
75
+ span.textContent = wallet.name
76
+ button.appendChild(span)
77
+
78
+ this.walletListTarget.appendChild(button)
79
+ })
80
+
81
+ this.walletListTarget.classList.remove("hidden")
82
+ }
83
+
84
+ selectWallet(event) {
85
+ const index = parseInt(event.currentTarget.dataset.walletIndex)
86
+ this.selectedWallet = this.availableWallets[index]
87
+ this.renderWalletList()
88
+ }
89
+
90
+ // Map wallet-standard wallets to their legacy window providers.
91
+ // Legacy connect() is called first to preserve user gesture context
92
+ // (Chrome only allows extension popups within a direct user action).
93
+ getLegacyProvider(walletName) {
94
+ const name = walletName.toLowerCase()
95
+ if (name.includes("phantom")) return window.phantom?.solana || window.solana
96
+ if (name.includes("solflare")) return window.solflare
97
+ if (name.includes("backpack")) return window.backpack
98
+ return null
99
+ }
100
+
101
+ async authenticate() {
102
+ if (!this.selectedWallet) {
103
+ this.showStatus("No Solana wallet found. Please install a Solana wallet extension.", "error")
104
+ return
105
+ }
106
+
107
+ try {
108
+ this.connectBtnTarget.disabled = true
109
+ this.connectBtnTarget.textContent = `Connecting to ${this.selectedWallet.name}...`
110
+
111
+ let publicKey
112
+ let signMessage
113
+
114
+ // Try legacy provider FIRST — this must happen immediately on user click
115
+ // so Chrome allows the extension popup to open.
116
+ const provider = this.getLegacyProvider(this.selectedWallet.name)
117
+
118
+ if (provider) {
119
+ // Legacy connect — happens immediately in the user gesture, popup shows
120
+ const response = await provider.connect()
121
+
122
+ // Different wallets return publicKey differently:
123
+ // Phantom: response.publicKey.toString()
124
+ // Solflare: response.publicKey?.toString() or provider.publicKey.toString()
125
+ // Backpack: response.publicKey.toString() or provider.publicKey.toString()
126
+ const pk = response?.publicKey || provider.publicKey
127
+ if (!pk) {
128
+ throw new Error("Wallet connected but no public key returned. Please try again.")
129
+ }
130
+ publicKey = pk.toString()
131
+
132
+ signMessage = async (messageBytes) => {
133
+ // signMessage API differs per wallet:
134
+ // Phantom: signMessage(bytes, "utf8") → { signature }
135
+ // Solflare: signMessage(bytes) → { signature }
136
+ // Backpack: signMessage(bytes) → Uint8Array
137
+ // Only pass encoding for Phantom; others don't accept it.
138
+ const isPhantom = provider.isPhantom === true
139
+ const result = isPhantom
140
+ ? await provider.signMessage(messageBytes, "utf8")
141
+ : await provider.signMessage(messageBytes)
142
+
143
+ if (result instanceof Uint8Array) return result
144
+ if (result?.signature) return new Uint8Array(result.signature)
145
+ return new Uint8Array(result)
146
+ }
147
+ } else {
148
+ // No legacy provider — use wallet-standard connect
149
+ const connectFeature = this.selectedWallet.features["standard:connect"]
150
+ const { accounts } = await connectFeature.connect()
151
+
152
+ if (!accounts || accounts.length === 0) {
153
+ throw new Error("No accounts returned. Please unlock your wallet and try again.")
154
+ }
155
+
156
+ const account = accounts[0]
157
+ publicKey = account.address
158
+
159
+ const signMessageFeature = this.selectedWallet.features[SolanaSignMessage]
160
+ signMessage = async (messageBytes) => {
161
+ const [{ signature }] = await signMessageFeature.signMessage(
162
+ { account, message: messageBytes }
163
+ )
164
+ return new Uint8Array(signature)
165
+ }
166
+ }
167
+
168
+ // Step 2: Request a nonce from the server
169
+ this.showSigning()
170
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
171
+ const nonceResponse = await fetch(this.nonceUrlValue, {
172
+ method: "POST",
173
+ headers: {
174
+ "Content-Type": "application/json",
175
+ "Accept": "application/json",
176
+ "X-CSRF-Token": csrfToken
177
+ },
178
+ body: JSON.stringify({ wallet_address: publicKey })
179
+ })
180
+
181
+ if (!nonceResponse.ok) {
182
+ throw new Error("Failed to get authentication challenge")
183
+ }
184
+
185
+ const { message } = await nonceResponse.json()
186
+
187
+ // Step 3: Sign the SIWS message
188
+ const encodedMessage = new TextEncoder().encode(message)
189
+ const signatureBytes = await signMessage(encodedMessage)
190
+
191
+ // Step 4: Send the signed message to the server for verification
192
+ const signatureString = Array.from(signatureBytes).join(",")
193
+
194
+ const verifyResponse = await fetch(this.verifyUrlValue, {
195
+ method: "POST",
196
+ headers: {
197
+ "Content-Type": "application/json",
198
+ "Accept": "application/json",
199
+ "X-CSRF-Token": csrfToken
200
+ },
201
+ body: JSON.stringify({
202
+ wallet_address: publicKey,
203
+ message: message,
204
+ signature: signatureString
205
+ })
206
+ })
207
+
208
+ if (!verifyResponse.ok) {
209
+ const error = await verifyResponse.json()
210
+ throw new Error(error.error || "Verification failed")
211
+ }
212
+
213
+ // Step 5: Redirect to dashboard
214
+ window.location.href = this.dashboardUrlValue
215
+
216
+ } catch (error) {
217
+ console.error("Wallet authentication error:", error)
218
+
219
+ if (error.message?.includes("User rejected") || error.message?.includes("cancelled")) {
220
+ this.showStatus("Sign-in cancelled", "warning")
221
+ } else if (error.message?.includes("Unexpected error")) {
222
+ this.showStatus(`Please unlock ${this.selectedWallet.name} and try again.`, "error")
223
+ } else {
224
+ this.showStatus(error.message || "Connection failed.", "error")
225
+ }
226
+
227
+ this.resetUI()
228
+ }
229
+ }
230
+
231
+ showSigning() {
232
+ this.connectBtnTarget.classList.add("hidden")
233
+ if (this.hasWalletListTarget) this.walletListTarget.classList.add("hidden")
234
+ this.signingTarget.classList.remove("hidden")
235
+ }
236
+
237
+ resetUI() {
238
+ this.connectBtnTarget.classList.remove("hidden")
239
+ this.connectBtnTarget.disabled = false
240
+ this.connectBtnTarget.textContent = "Connect Wallet"
241
+ if (this.hasWalletListTarget) this.walletListTarget.classList.remove("hidden")
242
+ this.signingTarget.classList.add("hidden")
243
+ }
244
+
245
+ showStatus(message, type = "info") {
246
+ const statusEl = this.statusTarget
247
+ statusEl.textContent = message
248
+ statusEl.classList.remove("hidden", "bg-red-900/50", "text-red-300", "bg-yellow-900/50", "text-yellow-300", "bg-green-900/50", "text-green-300")
249
+
250
+ switch (type) {
251
+ case "error":
252
+ statusEl.classList.add("bg-red-900/50", "text-red-300")
253
+ break
254
+ case "warning":
255
+ statusEl.classList.add("bg-yellow-900/50", "text-yellow-300")
256
+ break
257
+ default:
258
+ statusEl.classList.add("bg-green-900/50", "text-green-300")
259
+ }
260
+ statusEl.classList.remove("hidden")
261
+ }
262
+ }
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solrengine
4
+ module Auth
5
+ class ApplicationController < ActionController::Base
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solrengine
4
+ module Auth
5
+ module Concerns
6
+ module ControllerHelpers
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ helper_method :current_user, :logged_in?
11
+ end
12
+
13
+ private
14
+
15
+ def current_user
16
+ @current_user ||= Solrengine::Auth.configuration.user_model.find_by(id: session[:user_id]) if session[:user_id]
17
+ end
18
+
19
+ def logged_in?
20
+ current_user.present?
21
+ end
22
+
23
+ def authenticate!
24
+ redirect_to solrengine_auth.login_path unless logged_in?
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solrengine
4
+ module Auth
5
+ class SessionsController < ApplicationController
6
+ def new
7
+ # Renders the wallet connect view
8
+ end
9
+
10
+ def nonce
11
+ wallet_address = params[:wallet_address]
12
+
13
+ unless wallet_address.match?(Concerns::Authenticatable::SOLANA_ADDRESS_FORMAT)
14
+ return render json: { error: "Invalid wallet address" }, status: :unprocessable_entity
15
+ end
16
+
17
+ user_class = _user_class
18
+ user = user_class.find_or_initialize_by(wallet_address: wallet_address)
19
+ user.generate_nonce! if user.persisted?
20
+ user.save! unless user.persisted?
21
+
22
+ domain = Solrengine::Auth.configuration.domain
23
+ message = SiwsMessageBuilder.new(
24
+ domain: domain,
25
+ wallet_address: wallet_address,
26
+ nonce: user.nonce,
27
+ uri: request.base_url
28
+ ).build
29
+
30
+ render json: { message: message, nonce: user.nonce }
31
+ end
32
+
33
+ def create
34
+ wallet_address = params[:wallet_address]
35
+ message = params[:message]
36
+ signature = params[:signature]
37
+
38
+ user = _user_class.find_by(wallet_address: wallet_address)
39
+
40
+ unless user&.nonce_valid?
41
+ return render json: { error: "Authentication expired. Please try again." }, status: :unprocessable_entity
42
+ end
43
+
44
+ verifier = SiwsVerifier.new(
45
+ wallet_address: wallet_address,
46
+ message: message,
47
+ signature: signature
48
+ )
49
+
50
+ unless verifier.verify
51
+ return render json: { error: "Signature verification failed" }, status: :unauthorized
52
+ end
53
+
54
+ # Verify the nonce in the signed message matches the database nonce
55
+ message_nonce = message.match(/Nonce: ([a-f0-9]+)/)&.captures&.first
56
+ unless message_nonce.present? && ActiveSupport::SecurityUtils.secure_compare(message_nonce, user.nonce)
57
+ return render json: { error: "Nonce mismatch" }, status: :unauthorized
58
+ end
59
+
60
+ user.generate_nonce!
61
+
62
+ reset_session
63
+ session[:user_id] = user.id
64
+ render json: { success: true, wallet_address: user.wallet_address }
65
+ end
66
+
67
+ def destroy
68
+ reset_session
69
+ redirect_to Solrengine::Auth.configuration.after_sign_out_path, notice: "Disconnected"
70
+ end
71
+
72
+ private
73
+
74
+ def _user_class
75
+ Solrengine::Auth.configuration.user_model
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solrengine
4
+ module Auth
5
+ module Concerns
6
+ module Authenticatable
7
+ extend ActiveSupport::Concern
8
+
9
+ SOLANA_ADDRESS_FORMAT = /\A[1-9A-HJ-NP-Za-km-z]{32,44}\z/
10
+
11
+ included do
12
+ validates :wallet_address, presence: true, uniqueness: true,
13
+ format: { with: SOLANA_ADDRESS_FORMAT, message: "is not a valid Solana address" }
14
+
15
+ before_create :generate_nonce
16
+ end
17
+
18
+ def generate_nonce!
19
+ generate_nonce
20
+ save! if persisted?
21
+ end
22
+
23
+ def nonce_valid?
24
+ nonce.present? && nonce_expires_at.present? && nonce_expires_at > Time.current
25
+ end
26
+
27
+ private
28
+
29
+ def generate_nonce
30
+ nonce_ttl = Solrengine::Auth.configuration.nonce_ttl
31
+ self.nonce = SecureRandom.hex(16)
32
+ self.nonce_expires_at = nonce_ttl.from_now
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,37 @@
1
+ <div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-950 via-gray-900 to-purple-950">
2
+ <div class="max-w-md w-full mx-4" data-controller="wallet" data-wallet-nonce-url-value="<%= solrengine_auth.nonce_path %>" data-wallet-verify-url-value="<%= solrengine_auth.verify_path %>" data-wallet-dashboard-url-value="<%= Solrengine::Auth.configuration.after_sign_in_path %>">
3
+
4
+ <div class="text-center mb-8">
5
+ <h1 class="text-4xl font-bold text-white mb-2"><%= yield(:solrengine_auth_title) || "Sign In" %></h1>
6
+ <p class="text-gray-400">Connect your wallet to get started</p>
7
+ </div>
8
+
9
+ <div class="bg-gray-800/50 backdrop-blur border border-gray-700 rounded-2xl p-8 shadow-xl">
10
+ <div data-wallet-target="status" class="hidden mb-4 p-3 rounded-lg text-sm text-center"></div>
11
+
12
+ <div data-wallet-target="walletList" class="hidden mb-4 space-y-2"></div>
13
+
14
+ <button
15
+ data-wallet-target="connectBtn"
16
+ data-action="click->wallet#authenticate"
17
+ class="w-full cursor-pointer bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-500 hover:to-blue-500 text-white font-semibold py-3 px-6 rounded-xl transition-all duration-200 flex items-center justify-center gap-2"
18
+ >
19
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
20
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
21
+ </svg>
22
+ Connect Wallet
23
+ </button>
24
+
25
+ <div data-wallet-target="signing" class="hidden">
26
+ <div class="text-center">
27
+ <div class="animate-spin w-8 h-8 border-2 border-purple-500 border-t-transparent rounded-full mx-auto mb-3"></div>
28
+ <p class="text-gray-300">Approve the sign-in request in your wallet...</p>
29
+ </div>
30
+ </div>
31
+ </div>
32
+
33
+ <p class="text-center text-gray-500 text-sm mt-6">
34
+ Powered by SolRengine
35
+ </p>
36
+ </div>
37
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ Solrengine::Auth::Engine.routes.draw do
4
+ get "login", to: "sessions#new", as: :login
5
+ post "nonce", to: "sessions#nonce", as: :nonce
6
+ post "verify", to: "sessions#create", as: :verify
7
+ delete "logout", to: "sessions#destroy", as: :logout
8
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+
6
+ module Solrengine
7
+ module Auth
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ def self.next_migration_number(dirname)
14
+ if ActiveRecord.respond_to?(:timestamped_migrations) && ActiveRecord.timestamped_migrations
15
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
16
+ else
17
+ "%.3d" % (current_migration_number(dirname) + 1)
18
+ end
19
+ end
20
+
21
+ def create_initializer
22
+ template "initializer.rb", "config/initializers/solrengine_auth.rb"
23
+ end
24
+
25
+ def create_user_model
26
+ unless File.exist?("app/models/user.rb")
27
+ create_file "app/models/user.rb", <<~RUBY
28
+ class User < ApplicationRecord
29
+ include Solrengine::Auth::Concerns::Authenticatable
30
+ end
31
+ RUBY
32
+ else
33
+ unless File.read("app/models/user.rb").include?("Solrengine::Auth::Concerns::Authenticatable")
34
+ inject_into_class "app/models/user.rb", "User",
35
+ " include Solrengine::Auth::Concerns::Authenticatable\n\n"
36
+ end
37
+ end
38
+ end
39
+
40
+ def create_migration
41
+ migration_template "migration.rb.erb", "db/migrate/create_users_with_wallet_auth.rb"
42
+ rescue Rails::Generators::Error
43
+ say_status :skip, "Migration already exists", :yellow
44
+ end
45
+
46
+ def add_routes
47
+ route_string = 'mount Solrengine::Auth::Engine, at: "/auth"'
48
+ return if File.read("config/routes.rb").include?(route_string)
49
+
50
+ route route_string
51
+ end
52
+
53
+ def add_application_controller_helpers
54
+ controller_file = "app/controllers/application_controller.rb"
55
+ include_line = "include Solrengine::Auth::Concerns::ControllerHelpers"
56
+
57
+ return if File.read(controller_file).include?(include_line)
58
+
59
+ inject_into_class controller_file, "ApplicationController", " #{include_line}\n"
60
+ end
61
+
62
+ def show_post_install
63
+ say "\n SolRengine Auth installed!", :green
64
+ say " Run `rails db:migrate` to create the users table.\n\n"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,16 @@
1
+ Solrengine::Auth.configure do |config|
2
+ # The domain used in SIWS messages. Must match your app's domain.
3
+ config.domain = ENV.fetch("APP_DOMAIN", "localhost")
4
+
5
+ # How long a nonce is valid before expiring.
6
+ config.nonce_ttl = 5.minutes
7
+
8
+ # The model class used for wallet authentication (String or Class).
9
+ # config.user_class = "User"
10
+
11
+ # Where to redirect after sign-in (used by the JS wallet controller).
12
+ config.after_sign_in_path = "/"
13
+
14
+ # Where to redirect after sign-out.
15
+ config.after_sign_out_path = "/"
16
+ end
@@ -0,0 +1,19 @@
1
+ class CreateUsersWithWalletAuth < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ if table_exists?(:users)
4
+ add_column :users, :wallet_address, :string, null: false unless column_exists?(:users, :wallet_address)
5
+ add_index :users, :wallet_address, unique: true unless index_exists?(:users, :wallet_address)
6
+ add_column :users, :nonce, :string unless column_exists?(:users, :nonce)
7
+ add_column :users, :nonce_expires_at, :datetime unless column_exists?(:users, :nonce_expires_at)
8
+ else
9
+ create_table :users do |t|
10
+ t.string :wallet_address, null: false
11
+ t.string :nonce
12
+ t.datetime :nonce_expires_at
13
+
14
+ t.timestamps
15
+ end
16
+ add_index :users, :wallet_address, unique: true
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solrengine
4
+ module Auth
5
+ class Configuration
6
+ attr_accessor :domain, :nonce_ttl, :after_sign_in_path, :after_sign_out_path, :user_class, :chain_id
7
+
8
+ def initialize
9
+ @domain = "localhost"
10
+ @nonce_ttl = 5.minutes
11
+ @after_sign_in_path = "/"
12
+ @after_sign_out_path = "/"
13
+ @user_class = "User"
14
+ @chain_id = ENV.fetch("SOLANA_NETWORK", "mainnet")
15
+ end
16
+
17
+ def user_model
18
+ @user_class.is_a?(String) ? @user_class.constantize : @user_class
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solrengine
4
+ module Auth
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace Solrengine::Auth
7
+
8
+ initializer "solrengine-auth.assets" do |app|
9
+ app.config.assets.paths << root.join("app/assets/javascripts")
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solrengine
4
+ module Auth
5
+ # Builds a SIWS (Sign In With Solana) message following the standard format.
6
+ # Modeled after EIP-4361 (SIWE) adapted for Solana.
7
+ class SiwsMessageBuilder
8
+ def initialize(domain:, wallet_address:, nonce:, statement: nil, uri: nil, chain_id: nil)
9
+ @domain = domain
10
+ @wallet_address = wallet_address
11
+ @nonce = nonce
12
+ @statement = statement || "Sign in to #{domain}"
13
+ @uri = uri || "https://#{domain}"
14
+ @chain_id = chain_id || Solrengine::Auth.configuration.chain_id
15
+ @issued_at = Time.current.iso8601
16
+ end
17
+
18
+ def build
19
+ [
20
+ "#{@domain} wants you to sign in with your Solana account:",
21
+ @wallet_address,
22
+ "",
23
+ @statement,
24
+ "",
25
+ "URI: #{@uri}",
26
+ "Version: 1",
27
+ "Chain ID: #{@chain_id}",
28
+ "Nonce: #{@nonce}",
29
+ "Issued At: #{@issued_at}"
30
+ ].join("\n")
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ed25519"
4
+ require "base58"
5
+
6
+ module Solrengine
7
+ module Auth
8
+ # Verifies SIWS (Sign In With Solana) signed messages.
9
+ # Uses Ed25519 signature verification and validates domain
10
+ # to prevent cross-site replay attacks.
11
+ class SiwsVerifier
12
+ class VerificationError < StandardError; end
13
+
14
+ def initialize(wallet_address:, message:, signature:, domain: nil)
15
+ @wallet_address = wallet_address
16
+ @message = message
17
+ @signature = signature
18
+ @domain = domain || Solrengine::Auth.configuration.domain
19
+ end
20
+
21
+ def verify!
22
+ verify_message_format!
23
+ verify_domain!
24
+ verify_signature!
25
+ true
26
+ rescue Ed25519::VerifyError
27
+ raise VerificationError, "Invalid signature"
28
+ end
29
+
30
+ def verify
31
+ verify!
32
+ rescue VerificationError
33
+ false
34
+ end
35
+
36
+ private
37
+
38
+ def verify_message_format!
39
+ unless @message.include?(@wallet_address)
40
+ raise VerificationError, "Message does not contain the claimed wallet address"
41
+ end
42
+
43
+ unless extract_nonce.present?
44
+ raise VerificationError, "Message does not contain a nonce"
45
+ end
46
+ end
47
+
48
+ def verify_domain!
49
+ first_line = @message.lines.first&.strip
50
+ unless first_line == "#{@domain} wants you to sign in with your Solana account:"
51
+ raise VerificationError, "Message domain does not match expected domain"
52
+ end
53
+ end
54
+
55
+ def verify_signature!
56
+ pubkey_bytes = Base58.base58_to_binary(@wallet_address, :bitcoin)
57
+ signature_bytes = decode_signature(@signature)
58
+
59
+ verify_key = Ed25519::VerifyKey.new(pubkey_bytes)
60
+ verify_key.verify(signature_bytes, @message)
61
+ end
62
+
63
+ def extract_nonce
64
+ match = @message.match(/Nonce: ([a-f0-9]+)/)
65
+ match&.captures&.first
66
+ end
67
+
68
+ def decode_signature(signature)
69
+ bytes = if signature.match?(/\A[0-9,\s]+\z/)
70
+ values = signature.split(",").map(&:to_i)
71
+ raise VerificationError, "Invalid signature byte values" if values.any? { |v| v < 0 || v > 255 }
72
+ values.pack("C*")
73
+ else
74
+ Base64.strict_decode64(signature)
75
+ end
76
+ raise VerificationError, "Signature must be 64 bytes" unless bytes.bytesize == 64
77
+ bytes
78
+ rescue ArgumentError
79
+ raise VerificationError, "Invalid signature encoding"
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solrengine
4
+ module Auth
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "auth/version"
4
+ require_relative "auth/configuration"
5
+ require_relative "auth/siws_verifier"
6
+ require_relative "auth/siws_message_builder"
7
+ require_relative "auth/engine" if defined?(Rails::Engine)
8
+
9
+ module Solrengine
10
+ module Auth
11
+ class << self
12
+ def configuration
13
+ @configuration ||= Configuration.new
14
+ end
15
+
16
+ def configure
17
+ yield(configuration)
18
+ end
19
+ end
20
+ end
21
+ end
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: solrengine-auth
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jose Ferrer
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: ed25519
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.4'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.4'
41
+ - !ruby/object:Gem::Dependency
42
+ name: base58
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.2'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.2'
55
+ description: Drop-in wallet authentication for Rails apps. Sign in with Phantom, Solflare,
56
+ Backpack, or any Wallet Standard compatible Solana wallet. Ed25519 signature verification
57
+ in Ruby.
58
+ email:
59
+ - estoy@moviendo.me
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - README.md
65
+ - app/assets/javascripts/solrengine/auth/wallet_controller.js
66
+ - app/controllers/solrengine/auth/application_controller.rb
67
+ - app/controllers/solrengine/auth/concerns/controller_helpers.rb
68
+ - app/controllers/solrengine/auth/sessions_controller.rb
69
+ - app/models/solrengine/auth/concerns/authenticatable.rb
70
+ - app/views/solrengine/auth/sessions/new.html.erb
71
+ - config/routes.rb
72
+ - lib/generators/solrengine/auth/install_generator.rb
73
+ - lib/generators/solrengine/auth/templates/initializer.rb
74
+ - lib/generators/solrengine/auth/templates/migration.rb.erb
75
+ - lib/solrengine/auth.rb
76
+ - lib/solrengine/auth/configuration.rb
77
+ - lib/solrengine/auth/engine.rb
78
+ - lib/solrengine/auth/siws_message_builder.rb
79
+ - lib/solrengine/auth/siws_verifier.rb
80
+ - lib/solrengine/auth/version.rb
81
+ homepage: https://github.com/solrengine/auth
82
+ licenses:
83
+ - MIT
84
+ metadata:
85
+ homepage_uri: https://github.com/solrengine/auth
86
+ source_code_uri: https://github.com/solrengine/auth
87
+ changelog_uri: https://github.com/solrengine/auth/blob/main/CHANGELOG.md
88
+ post_install_message:
89
+ rdoc_options: []
90
+ require_paths:
91
+ - lib
92
+ required_ruby_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: 3.2.0
97
+ required_rubygems_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ requirements: []
103
+ rubygems_version: 3.5.22
104
+ signing_key:
105
+ specification_version: 4
106
+ summary: Solana wallet authentication for Rails using SIWS (Sign In With Solana)
107
+ test_files: []