docs/plans/6.0-platform-auth.md
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
Two changes in one plan:
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.
Rename User → Customer. Spree.user_class → Spree.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.
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.
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.
Already built. The core auth stack is already implemented and operational:
Spree::Authentication::Strategies::BaseStrategy — pluggable strategy patternSpree::Authentication::Strategies::EmailPasswordStrategy — email+password with bcryptSpree::UserIdentity — OAuth/social login identity storagepk_* + secret sk_*)generates_token_for (Rails 7.1+)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" 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.
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.
| Component | Status | Location |
|---|---|---|
| JWT issuance/validation | Built | api/controllers/concerns/spree/api/v3/jwt_authentication.rb |
| API key auth (pk/sk) | Built | api/controllers/concerns/spree/api/v3/api_key_authentication.rb |
| Strategy framework | Built | core/app/models/spree/authentication/strategies/base_strategy.rb |
| Email+password strategy | Built | core/app/models/spree/authentication/strategies/email_password_strategy.rb |
| OAuth identity storage | Built | core/app/models/spree/user_identity.rb |
| Password reset tokens | Built | core/app/models/concerns/spree/user_methods.rb (via generates_token_for) |
| Store API auth endpoints | Built | api/controllers/spree/api/v3/store/auth_controller.rb |
| Rate limiting | Available | Rails 7.1+ rate_limit DSL |
| Devise (legacy admin only) | Conditional | admin/app/controllers/spree/admin/user_sessions_controller.rb |
| Current | Purpose | Default class |
|---|---|---|
Spree.user_class | Storefront customer | Spree::LegacyUser |
Spree.admin_user_class | Admin/staff user | Spree::LegacyAdminUser |
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.store_api) and admin (admin_api). Already implemented and working.Spree.store_authentication_strategies and Spree.admin_authentication_strategies for registering custom providers.rate_limit DSL on login, register, and password reset endpoints. Baked in, not optional.failed_attempts and locked_at on Customer/AdminUser model. Simple, no Devise dependency.spree/authentication/devise/ generator directory.Spree.store_authentication_strategies lets developers plug in Auth0, Clerk, Firebase, LDAP, etc. by implementing BaseStrategy.Spree.user_class → Spree.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.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.cust_ for Customer, adm_ for AdminUser.Spree::UserMethods → Spree::CustomerMethods, Spree::AdminUserMethods stays as-is. Aliases for one release.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.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
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
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
# 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
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
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
# 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
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
No data migration is needed — just change user_class to customer_class in Spree initializer. For both Devise-based and custom user models.
Spree.user_class → Spree.customer_class (deprecation alias for user_class)Spree.admin_user_class stays as-isSpree::UserMethods → Spree::CustomerMethods (deprecation alias)Spree::AdminUserMethods stays as-isSpree::LegacyUser → Spree::Customer (new default)Spree::LegacyAdminUser → Spree::AdminUser (new default, production-ready)user_id FK → customer_id on 11 customer-facing tables (see decisions.md entry)belongs_to :user → belongs_to :customer on those modelsSpree::RefreshToken modelAuthController to issue refresh tokens alongside access JWTsPOST /auth/refresh and POST /auth/logout endpointsspree/core/lib/generators/spree/authentication/devise/ directoryspree/admin/lib/generators/spree/admin/devise/ directoryspree/admin/app/controllers/spree/admin/user_sessions_controller.rbspree/admin/app/controllers/spree/admin/user_passwords_controller.rbdefined?(Devise::...) conditional checksSpree::LegacyUser and Spree::LegacyAdminUseruser_class (keep only customer_class)Spree::OauthAccessToken if any references remaindevise_for, no new Devise controller inheritance.Spree.user_class / Spree.admin_user_class for now — user_class will become customer_class in 6.0, admin_user_class stays.BaseStrategy for auth logic.None at this time.
spree/core/app/models/spree/authentication/strategies/base_strategy.rbspree/api/app/controllers/concerns/spree/api/v3/jwt_authentication.rbspree/core/app/models/spree/legacy_user.rb, spree/core/app/models/spree/legacy_admin_user.rbspree/core/app/models/concerns/spree/user_methods.rb, spree/core/app/models/concerns/spree/admin_user_methods.rbspree/core/lib/generators/spree/authentication/devise/6.0-admin-spa.md (React admin removes Rails admin, kills Devise dependency)6.0-channels-catalogs-b2b.md (Company → CompanyContact references customer/staff)