Back to Spree

Platform-Owned Authentication & User Model Rename

docs/plans/6.0-platform-auth.md

5.4.216.0 KB
Original Source

Platform-Owned Authentication & User Model Rename

Status: In Progress (RefreshToken shipped in 5.4 ✓, rest in 6.0) Target: Spree 5.4 (RefreshToken ✓) + Spree 6.0 (Devise removal, User→Customer rename) Depends on: Admin SPA (6.0-admin-spa.md — removes Rails admin, kills Devise dependency) for Devise removal Author: Damian + Claude Last updated: 2026-03-17

Summary

Two changes in one plan:

  1. Drop Devise, own the auth stack. Spree already has a JWT-based, strategy-pattern auth system that's fully independent of Devise for the API layer. Devise is only used by the deprecated Rails admin (being replaced by React SPA in 6.0). Remove the Devise dependency entirely and make Spree's built-in auth the default — has_secure_password, JWT tokens, pluggable strategies.

  2. Rename User → Customer. Spree.user_classSpree.customer_class. Default customer model becomes Spree::Customer (replaces Spree::LegacyUser). Spree.admin_user_class stays, default becomes Spree::AdminUser (replaces Spree::LegacyAdminUser). "User" is ambiguous — "customer" is what storefront users actually are.

Problem

Devise dependency

  1. Wrong tool for headless. Devise is a full-stack Rails auth library — controllers, views, routes, Warden middleware, cookie sessions. Spree is API-first with JWT tokens. Devise provides ~5% of its surface area to Spree (password hashing, reset tokens), the rest is dead weight.

  2. Sends the wrong signal. TypeScript developers evaluating Spree see Devise in the dependency chain and think "this is a Rails monolith." Removing it strengthens the API-first positioning.

  3. Already built. The core auth stack is already implemented and operational:

    • Spree::Authentication::Strategies::BaseStrategy — pluggable strategy pattern
    • Spree::Authentication::Strategies::EmailPasswordStrategy — email+password with bcrypt
    • Spree::UserIdentity — OAuth/social login identity storage
    • JWT issuance and validation in API v3 controllers
    • API key system (publishable pk_* + secret sk_*)
    • Password reset via generates_token_for (Rails 7.1+)
  4. Conditional dependency anyway. Devise is not in any gemspec — it's already optional via conditional inheritance in the legacy admin controllers (defined?(Devise::SessionsController) ? Devise::SessionsController : ...). The legacy Rails admin is being replaced by the React SPA.

User naming

  1. "User" is ambiguous. Spree.user_class could mean customer, admin, API consumer, or anyone. Every new developer asks "which user?" The model is Spree::LegacyUser — the name itself admits the problem.

  2. API already uses "customer." Store API endpoints are /store/customers, /store/customer, the serializer is CustomerSerializer. But the model is Spree.user_class. This mismatch confuses developers.

Current State

Auth system

ComponentStatusLocation
JWT issuance/validationBuiltapi/controllers/concerns/spree/api/v3/jwt_authentication.rb
API key auth (pk/sk)Builtapi/controllers/concerns/spree/api/v3/api_key_authentication.rb
Strategy frameworkBuiltcore/app/models/spree/authentication/strategies/base_strategy.rb
Email+password strategyBuiltcore/app/models/spree/authentication/strategies/email_password_strategy.rb
OAuth identity storageBuiltcore/app/models/spree/user_identity.rb
Password reset tokensBuiltcore/app/models/concerns/spree/user_methods.rb (via generates_token_for)
Store API auth endpointsBuiltapi/controllers/spree/api/v3/store/auth_controller.rb
Rate limitingAvailableRails 7.1+ rate_limit DSL
Devise (legacy admin only)Conditionaladmin/app/controllers/spree/admin/user_sessions_controller.rb

User models

CurrentPurposeDefault class
Spree.user_classStorefront customerSpree::LegacyUser
Spree.admin_user_classAdmin/staff userSpree::LegacyAdminUser

Key Decisions (do not deviate without discussion)

Auth

  • Drop Devise entirely. No conditional fallbacks, no generator, no devise_for routes. The legacy Rails admin is gone in 6.0 (replaced by React SPA).
  • has_secure_password is the foundation. Rails core, no gem dependency. Handles bcrypt hashing, authenticate(password) method, password confirmation validation.
  • JWT stays as-is. Short-lived access tokens, separate audiences for store (store_api) and admin (admin_api). Already implemented and working.
  • Strategy pattern stays as-is. Spree.store_authentication_strategies and Spree.admin_authentication_strategies for registering custom providers.
  • Add refresh token support (5.4). Short-lived access JWT (15 min) + long-lived refresh token (stored in DB, rotatable, revocable). Better UX for React admin and Next.js storefront — silent refresh instead of forced re-login. Ships in 5.4 — can't wait for 6.0 because every storefront has broken session management without it.
  • Rate limiting on auth endpoints. Use Rails rate_limit DSL on login, register, and password reset endpoints. Baked in, not optional.
  • Account lockout after failed attempts. Track failed_attempts and locked_at on Customer/AdminUser model. Simple, no Devise dependency.
  • Remove all Devise generators. Drop spree/authentication/devise/ generator directory.
  • Keep the adapter pattern. Spree.store_authentication_strategies lets developers plug in Auth0, Clerk, Firebase, LDAP, etc. by implementing BaseStrategy.

User → Customer/AdminUser rename

  • Spree.user_classSpree.customer_class. Deprecation alias for one release.
  • Spree.admin_user_class stays as-is. Model stays Spree::AdminUser, accessor stays admin_user_class. Only the default implementation changes from LegacyAdminUser to AdminUser.
  • Default models: Spree::Customer and Spree::AdminUser. Replace Spree::LegacyUser and Spree::LegacyAdminUser.
  • Spree::Customer includes has_secure_password and all auth concerns by default. Production-ready out of the box, not a "legacy" placeholder.
  • Spree::AdminUser includes has_secure_password and admin auth concerns.
  • Prefix IDs: cust_ for Customer, adm_ for AdminUser.
  • Concerns renamed: Spree::UserMethodsSpree::CustomerMethods, Spree::AdminUserMethods stays as-is. Aliases for one release.
  • Database tables stay configurable. Spree.customer_class can point to any model — User, Customer, whatever the app uses. The rename is about the Spree interface and defaults, not forcing a table name.

Design Details

Spree::Customer (replaces LegacyUser)

ruby
class Spree::Customer < Spree.base_class
  has_prefix_id :cust

  has_secure_password

  include Spree::CustomerMethods      # orders, addresses, payments, wishlists
  include Spree::Metafields
  include Spree::Metadata

  has_many :identities, class_name: 'Spree::UserIdentity', as: :user, dependent: :destroy

  validates :email, presence: true, uniqueness: { case_sensitive: false }

  # Account security
  attribute :failed_attempts, :integer, default: 0
  attribute :locked_at, :datetime

  def locked?
    locked_at.present? && locked_at > 30.minutes.ago
  end

  def record_failed_attempt!
    increment!(:failed_attempts)
    update!(locked_at: Time.current) if failed_attempts >= 5
  end

  def reset_failed_attempts!
    update!(failed_attempts: 0, locked_at: nil)
  end
end

Spree::AdminUser (replaces LegacyAdminUser)

ruby
class Spree::AdminUser < Spree.base_class
  has_prefix_id :adm

  has_secure_password

  include Spree::AdminUserMethods      # roles, permissions, store access
  include Spree::Metafields
  include Spree::Metadata

  has_many :identities, class_name: 'Spree::UserIdentity', as: :user, dependent: :destroy

  validates :email, presence: true, uniqueness: { case_sensitive: false }

  attribute :failed_attempts, :integer, default: 0
  attribute :locked_at, :datetime
end

Refresh Token Model

ruby
class Spree::RefreshToken < Spree.base_class
  has_prefix_id :rt

  belongs_to :user, polymorphic: true  # Customer or AdminUser

  has_secure_token :token

  validates :token, :expires_at, presence: true

  scope :active, -> { where('expires_at > ?', Time.current) }
  scope :expired, -> { where('expires_at <= ?', Time.current) }

  attribute :expires_at, :datetime
  attribute :ip_address, :string
  attribute :user_agent, :string

  def expired?
    expires_at <= Time.current
  end

  def rotate!
    transaction do
      destroy!
      self.class.create!(
        user: user,
        expires_at: 30.days.from_now,
        ip_address: ip_address,
        user_agent: user_agent
      )
    end
  end
end

Auth API Endpoints

# Store API
POST   /api/v3/store/auth/login          # email+password → access JWT + refresh token
POST   /api/v3/store/auth/refresh         # refresh token → new access JWT + rotated refresh token
POST   /api/v3/store/auth/logout          # revoke refresh token
POST   /api/v3/store/auth/oauth/callback  # OAuth provider callback → JWT + refresh token

POST   /api/v3/store/customers            # register → JWT + refresh token
POST   /api/v3/store/customer/password_resets          # request reset email
PATCH  /api/v3/store/customer/password_resets/:token   # consume token, set password → JWT

# Admin API
POST   /api/v3/admin/auth/login           # email+password → access JWT + refresh token
POST   /api/v3/admin/auth/refresh         # refresh token → new access JWT
POST   /api/v3/admin/auth/logout          # revoke refresh token

Auth Flow

1. Customer visits storefront
2. Login: POST /auth/login { email, password }
   → EmailPasswordStrategy.authenticate(email, password, request_env)
   → Strategy returns success(customer) or failure(message)
   → Controller issues: { access_token: JWT (15 min), refresh_token: "rt_xxx" (30 days) }

3. API requests: Authorization: Bearer <access_token>
   → JwtAuthentication concern validates, sets current_customer

4. Token expires (15 min)
   → POST /auth/refresh { refresh_token: "rt_xxx" }
   → Validate refresh token, rotate it, issue new access JWT
   → Returns: { access_token: new JWT, refresh_token: "rt_yyy" (rotated) }

5. Logout: POST /auth/logout { refresh_token: "rt_xxx" }
   → Delete refresh token from DB

Configuration

ruby
Spree.configure do |config|
  # Customer auth strategies (extensible)
  config.store_authentication_strategies = {
    email: Spree::Authentication::Strategies::EmailPasswordStrategy
    # Add custom: google: MyApp::Auth::GoogleStrategy
  }

  # Admin auth strategies
  config.admin_authentication_strategies = {
    email: Spree::Authentication::Strategies::EmailPasswordStrategy
  }

  # JWT config
  config.jwt_access_token_expiry = 15.minutes
  config.jwt_secret = Rails.application.secret_key_base

  # Refresh token config
  config.refresh_token_expiry = 30.days

  # Account lockout
  config.max_failed_login_attempts = 5
  config.lockout_duration = 30.minutes
end

Custom Auth Provider Example

ruby
# Using Auth0
class MyApp::Auth::Auth0Strategy < Spree::Authentication::Strategies::BaseStrategy
  def provider
    :auth0
  end

  def authenticate(email, token, request_env = {})
    # Verify Auth0 JWT
    payload = Auth0Client.verify(token)
    user = find_user_by_email(payload['email']) ||
           find_or_create_user_from_oauth(
             provider: 'auth0',
             uid: payload['sub'],
             info: { email: payload['email'], name: payload['name'] }
           )
    success(user)
  rescue Auth0Client::InvalidToken => e
    failure(e.message)
  end
end

# Register in config
Spree.configure do |config|
  config.store_authentication_strategies[:auth0] = MyApp::Auth::Auth0Strategy
end

Migration Path

Phase 1: Customer/AdminUser models + has_secure_password

ruby
class CreateSpreeCustomersAndAdminUsers < ActiveRecord::Migration[7.2]
  def change
    # If app uses custom user tables, these aren't needed.
    # For new installs using Spree defaults:

    create_table :spree_customers do |t|
      t.string :email, null: false
      t.string :password_digest, null: false
      t.integer :failed_attempts, null: false, default: 0
      t.datetime :locked_at
      t.jsonb :metadata
      t.timestamps
    end
    add_index :spree_customers, :email, unique: true

    create_table :spree_admin_users do |t|
      t.string :email, null: false
      t.string :password_digest, null: false
      t.integer :failed_attempts, null: false, default: 0
      t.datetime :locked_at
      t.jsonb :metadata
      t.timestamps
    end
    add_index :spree_admin_users, :email, unique: true

    # Refresh tokens
    create_table :spree_refresh_tokens do |t|
      t.string :token, null: false
      t.references :user, polymorphic: true, null: false
      t.datetime :expires_at, null: false
      t.string :ip_address
      t.string :user_agent
      t.timestamps
    end
    add_index :spree_refresh_tokens, :token, unique: true
    add_index :spree_refresh_tokens, [:user_type, :user_id]
    add_index :spree_refresh_tokens, :expires_at
  end
end

Phase 2: Data migration (for existing Devise-based installs)

No data migration is needed — just change user_class to customer_class in Spree initializer. For both Devise-based and custom user models.

Phase 3: Rename config accessors + concerns

  • Spree.user_classSpree.customer_class (deprecation alias for user_class)
  • Spree.admin_user_class stays as-is
  • Spree::UserMethodsSpree::CustomerMethods (deprecation alias)
  • Spree::AdminUserMethods stays as-is
  • Spree::LegacyUserSpree::Customer (new default)
  • Spree::LegacyAdminUserSpree::AdminUser (new default, production-ready)
  • Rename user_id FK → customer_id on 11 customer-facing tables (see decisions.md entry)
  • Update belongs_to :userbelongs_to :customer on those models

Phase 4: Refresh token support

  • Create Spree::RefreshToken model
  • Update AuthController to issue refresh tokens alongside access JWTs
  • Add POST /auth/refresh and POST /auth/logout endpoints
  • Add rate limiting to auth endpoints
  • Add account lockout to Customer/AdminUser

Phase 5: Remove Devise

  • Delete spree/core/lib/generators/spree/authentication/devise/ directory
  • Delete spree/admin/lib/generators/spree/admin/devise/ directory
  • Delete spree/admin/app/controllers/spree/admin/user_sessions_controller.rb
  • Delete spree/admin/app/controllers/spree/admin/user_passwords_controller.rb
  • Remove all defined?(Devise::...) conditional checks
  • Remove Warden references from controller helpers
  • Delete Spree::LegacyUser and Spree::LegacyAdminUser

Phase 6: Cleanup (still 6.0)

  • Remove deprecation alias for user_class (keep only customer_class)
  • Remove Spree::OauthAccessToken if any references remain
  • Update all docs referencing Devise

Constraints on Current Work

  • Don't add new Devise dependencies. No new devise_for, no new Devise controller inheritance.
  • Use Spree.user_class / Spree.admin_user_class for nowuser_class will become customer_class in 6.0, admin_user_class stays.
  • New auth features should use the strategy pattern. Don't bypass BaseStrategy for auth logic.
  • JWT tokens are the only API auth mechanism. No cookie sessions in API controllers.

Open Questions

None at this time.

References

  • Current auth strategy: spree/core/app/models/spree/authentication/strategies/base_strategy.rb
  • Current JWT implementation: spree/api/app/controllers/concerns/spree/api/v3/jwt_authentication.rb
  • Current user models: spree/core/app/models/spree/legacy_user.rb, spree/core/app/models/spree/legacy_admin_user.rb
  • Current user concerns: spree/core/app/models/concerns/spree/user_methods.rb, spree/core/app/models/concerns/spree/admin_user_methods.rb
  • Devise generators (to be removed): spree/core/lib/generators/spree/authentication/devise/
  • Related plan: 6.0-admin-spa.md (React admin removes Rails admin, kills Devise dependency)
  • Related plan: 6.0-channels-catalogs-b2b.md (Company → CompanyContact references customer/staff)