devise.cr

devise.cr

A Crystal port of Devise 4.9.4 — Rails authentication: models, sessions, controllers/routes, mailer, OmniAuth and test helpers. Namespace Devise mirrors the original 1:1; the Warden stack is replaced by an HTTP::Handler-based warden-lite with byte-compatible session keys and cookies. Estimated parity ~80%. Suite: 145/145.

⚠️ EXPERIMENTAL FRAMEWORK, DO NOT USE IN PRODUCTION YET.

Installation

dependencies:
  devise_cr:
    github: OrelSokolov/devise.cr
shards install

Depends on active_record.cr, action_pack.cr, action_view.cr, action_mailer.cr, active_support.cr.

Usage

class User < ActiveRecord::Base
  devise :database_authenticatable, :registerable, :recoverable,
         :rememberable, :trackable, :validatable, :confirmable,
         :lockable, :timeoutable
end

# routes
devise_for :users

# controllers
class ApplicationController < ActionController::Base
  before_action :authenticate_user!   # current_user, user_signed_in?
end

Feature comparison vs devise 4.9.4

Model modules

Feature Original (devise) Port (devise.cr) Status
database_authenticatable (virtual password/password_confirmation, valid_password?, update_with/without_password) ✅ bcrypt (stdlib) with pepper, byte-compatible with the gem (Ruby hashes rehash identically); passwords of exactly 72 bytes are truncated to 71 (Crystal bcrypt NUL quirk) ✅
registerable ✅ Intercepts new/create/create! with password-before-save ✅
validatable ✅ Standard email/password validations 1:1; uniqueness via WHERE LOWER(email) = LOWER(?) ✅
rememberable ✅ authenticatable_salt, serialize_into/from_cookie ✅
recoverable ✅ Token find/reset, 6 h period, token cleanup, reset_password_by_token (expired/blank/short password does not consume the token), send_reset_password_instructions(email) with :not_found ✅
confirmable ✅ Raw token (as in 4.9.4) + digest fallback, reconfirmable (unconfirmed_email), skip_confirmation!, active_for_authentication? ✅
trackable ✅ update_tracked_fields!(Devise::RequestInfo) — counts, current/last sign_in at+ip ✅
lockable ✅ failed_attempts/maximum_attempts, lock_access!/unlock_access_by_token, lock_expired, :locked/:last_attempt messages, lock/unlock/:both strategies ✅
timeoutable ✅ Ruby-semantics timedout? ✅
omniauthable ✅ AuthHash schema 1:1 (omniauth 2.1.3), Google (omniauth-google-oauth2 1.1.1) and GitHub (omniauth-github 2.0.1) strategies on stdlib HTTP::Client, from_omniauth(auth) generator idiom ✅
Token generator (friendly_token, PBKDF2 digest) ✅ Byte-compatible digest (PBKDF2-HMAC-SHA1 65536/64, salt "Devise <column>" → HMAC-SHA256 hex) ✅
JWT sessions not in gem src/jwt.cr: HS256 access + opaque refresh tokens with rotation/revocation (own module, cross-checked against ruby-jwt) ➕

Routes, controllers & helpers (HTTP layer)

Feature Original Port Status
devise_for :users (path:, controllers:, path_names:, only:, except:) — `/users/sign_in sign_up sign_out password
Sessions/Registrations/Passwords/Confirmations/Unlocks/OmniauthCallbacks controllers, customizable by inheritance ✅ render_page: app view if present, else built-in form; statuses/flash from en.yml 1:1 ✅
Warden session key warden.user.<scope>.key = [[id], salt], remember_<scope>_token cookie ✅ Warden-lite over cookie sessions; remember cookie via permanent.signed with a JSON payload ✅
authenticate_user!, user_signed_in?, current_user, require_no_authentication, after_sign_in_path_for ✅ Same; one scope per request (default scope — others via Devise.current(self, "admin")) 🔶
Devise::Mailer (confirmation/reset/unlock) ✅ Five gem actions with en.yml subjects via action_mailer.cr; headers oracle-verified ✅
Views generator rails g devise:views Devise::ViewsGenerator.run (crails g devise:views) ✅
Test helpers: Devise::Test::IntegrationHelpers (sign_in user, sign_out) ✅ Byte-compatible warden key written directly into the controller session ✅
ControllerHelpers (ActionController::TestCase variant) ✅ Not ported — IntegrationHelpers covers the contract ❌
omniauthable request/callback routes (/:resource/auth/:provider(/callback)) ✅ Passthru 404 or redirect to authorize_url; per-provider generated actions ✅
saml, devise-otp, other community extensions ✅ (separate gems) Not ported ⬜

Config & i18n

Feature Original Port Status
config.mailer_sender/password_length/remember_for/timeout_in/maximum_attempts/unlock_strategy/pepper/stretches ✅ Same names (initializers + class-level *_within overrides) ✅
en.yml dictionary ✅ Byte-identical src/locales/en.yml; Devise.t/t! with dot-paths, resource-prefix fallback, %{} interpolation, one/other pluralization; Devise.locale=/load_locale (see devise_i18n.cr for 73 community locales) ✅
Missing-key semantics raise/"Translation missing" Falls back to en for fully missing keys (Ruby-I18n nil values still mean "missing") 🔶

Deviations & Crystal-specific notes

  • Model class-level operations go through typed Proc bridges and a compile-time MAPPINGS registry (no runtime constantize).
  • Trackable fields are not updated automatically in controllers.
  • Rails-internal session keys (session_id/flash internals) are not reproduced (cookie-session jar); wrong password renders the form with 422 (as 4.9 with responder.error_status).
  • OmniAuth failure flash omits the %{reason} part (the port does not know the reason).

Testing

crystal spec   # 145/145, repeat run green

Oracle-first: oracle/cases.rb (66 model cases, AR sqlite, devise 4.9.4), oracle/http_cases.rb (74 cases recorded in a throwaway Rails 8.1.4 app: route table, register/sign-in/out statuses, warden key, remember cookie, failure app, password reset by mail token, confirmation, omniauth passthru), oracle/omniauth_cases.rb (26 + 12 Google/GitHub cases), oracle/test_helpers_cases.rb (14 cases, devise 4.9.4 + warden 1.2.9), oracle/i18n_cases.rb (15 cases) — all replayed by Crystal mirrors without editing the cases.

Repository

devise.cr

Owner
Statistic
  • 0
  • 0
  • 0
  • 1
  • 4
  • about 2 hours ago
  • October 12, 2026
License

Links
Synced at

Thu, 15 Oct 2026 22:14:44 GMT

Languages