Skip to main content

Documentation Index

Fetch the complete documentation index at: https://spreecommerce.org/docs/llms.txt

Use this file to discover all available pages before exploring further.

Overview

Spree’s Store API ships with a pluggable authentication system. You register a strategy class for a named provider, and the existing login endpoints will dispatch to it — no controller patching, no route overrides, no fork. By the end of this guide you’ll have:
  • A custom strategy that verifies a third-party JWT against a JWKS endpoint
  • A user account auto-provisioned on first login, reused on subsequent logins
  • A standard Spree-issued JWT + refresh token returned to the client
  • All Store API endpoints (cart, checkout, account) protected by Spree’s own JWT — the third-party token is only used at the exchange step
The same pattern works for Admin API integrations via admin_authentication_strategies. Examples in this guide target the Store API; substitute Spree.admin_user_class and the admin endpoints where noted.

Architecture

Client → POST /api/v3/store/auth/login
            { provider: "my_idp", token: "<third-party JWT>" }


   AuthController looks up the strategy by `provider` key


   YourStrategy#authenticate
     • verifies the JWT against the IdP's JWKS
     • finds or creates a Spree::UserIdentity → Spree user
     • returns success(user) or failure(message)


   Spree issues its own JWT (HS256, iss=spree, aud=store_api)
   + a rotatable RefreshToken


Client → subsequent calls with `Authorization: Bearer <Spree JWT>`
The third-party JWT proves identity once, at login. After that, the client uses the Spree JWT for everything, and /auth/refresh rotates it via Spree’s own refresh-token mechanism. Your existing CanCanCan rules, current_user, and serializer params just work.

Step 1: Create the Strategy Class

Subclass Spree::Authentication::Strategies::BaseStrategy and implement two methods: provider (a string identifier) and authenticate (returns a Spree::ServiceModule::Result).
app/models/my_app/auth/external_jwt_strategy.rb
module MyApp
  module Auth
    class ExternalJwtStrategy < Spree::Authentication::Strategies::BaseStrategy
      PROVIDER = 'external_idp'.freeze

      def provider
        PROVIDER
      end

      def authenticate
        token = params[:token] || extract_bearer
        return failure(I18n.t('spree.api.unauthorized')) if token.blank?

        payload = verify_with_jwks(token)

        user = find_or_create_user_from_oauth(
          provider: PROVIDER,
          uid:      payload.fetch('sub'),
          info:     {
            email:      payload['email'],
            first_name: payload['given_name'],
            last_name:  payload['family_name']
          }
        )

        success(user)
      rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::InvalidIssuerError, JWT::InvalidAudError, KeyError => e
        failure(e.message)
      end

      private

      def verify_with_jwks(token)
        jwks_loader = ->(opts) { jwks(force: opts[:invalidate]) }

        JWT.decode(
          token, nil, true,
          algorithms: ['RS256'],
          iss:        ENV.fetch('EXTERNAL_IDP_ISSUER'),
          aud:        ENV.fetch('EXTERNAL_IDP_AUDIENCE'),
          verify_iss: true,
          verify_aud: true,
          jwks:       jwks_loader
        ).first
      end

      def jwks(force: false)
        Rails.cache.fetch('external_idp:jwks', expires_in: 1.hour, force: force) do
          uri = URI(ENV.fetch('EXTERNAL_IDP_JWKS_URL'))
          JSON.parse(Net::HTTP.get(uri))
        end
      end

      def extract_bearer
        header = request_env['HTTP_AUTHORIZATION'].to_s
        header.start_with?('Bearer ') ? header.split(' ', 2).last : nil
      end
    end
  end
end

What the base class gives you

Spree::Authentication::Strategies::BaseStrategy (in spree_core) exposes a few helpers so your subclass stays small:
HelperPurpose
success(user)Wrap a user in a successful ServiceModule::Result
failure(message)Wrap an error message in a failed result
find_user_by_email(email)Lookup against Spree.user_class
find_or_create_user_from_oauth(provider:, uid:, info:, tokens: {})Calls Spree::UserIdentity.find_or_create_from_oauth with the right user_class
params, request_env, user_classReader access to the controller-supplied inputs
find_or_create_user_from_oauth returns the user, not the identity. It creates the Spree::UserIdentity row on first login (mapping provider + uid → user) and reuses it on subsequent logins — so repeat sign-ins land on the same Spree customer.

Step 2: Register the Strategy

Add the strategy to Spree.store_authentication_strategies in an initializer. The key you choose here is what clients will send as provider in the login payload.
config/initializers/spree.rb
Rails.application.config.after_initialize do
  Spree.store_authentication_strategies.add(:external_idp, MyApp::Auth::ExternalJwtStrategy)
end
Spree.store_authentication_strategies is a Spree::Authentication::StrategyRegistry. The full API:
MethodPurpose
add(key, strategy_class)Register a strategy. Overwrites any existing entry under the same key — that’s how you swap the built-in :email strategy.
remove(key)Unregister a strategy. Idempotent (returns nil if the key was never registered).
[key]Look up a registered class.
key?(key), keys, values, each, to_hStandard introspection.
Spree::UserIdentity validates that provider is a registered strategy key. Registration must happen during boot — before the first login attempt.
For an admin-side equivalent, register under Spree.admin_authentication_strategies instead and instantiate your strategy against Spree.admin_user_class:
Spree.admin_authentication_strategies.add(:okta, MyApp::Auth::OktaStrategy)

Step 3: Call the Exchange Endpoint

POST /api/v3/store/auth/login is the single dispatcher. The provider field in the body selects the strategy — omit it for built-in email/password, set it to your registered key for everything else. The remaining body fields are whatever your strategy reads from params.
POST /api/v3/store/auth/login
X-Spree-API-Key: pk_your_publishable_key
Content-Type: application/json

{
  "provider": "external_idp",
  "token":    "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIs..."
}
The response is the standard Spree auth payload:
{
  "token":         "<Spree HS256 JWT, aud=store_api>",
  "refresh_token": "rt_xxxxxxxxxxxx",
  "user":          { "id": "user_...", "email": "...", "first_name": "...", "...": "..." }
}
From here, the client sends Authorization: Bearer <Spree JWT> on every subsequent Store API call. When the JWT expires (default: 1 hour), the client hits POST /api/v3/store/auth/refresh with the refresh token to rotate both. From @spree/sdk the same call looks like:
import { createClient } from '@spree/sdk'

const client = createClient({ baseUrl: 'https://your-store.com', publishableKey: '<pk>' })

const auth = await client.auth.login({
  provider: 'external_idp',
  token:    thirdPartyJwt,
})
LoginCredentials is a discriminated union — pass { email, password } for the built-in strategy, or { provider, ...customFields } for any strategy you registered.

Account Linking

The naive flow above will create a brand new Spree user the first time a given (provider, uid) is seen — even if a user with the same email already exists from a password signup. If you want same-email-means-same-customer, look up by email first and attach an identity to the existing user:
def authenticate
  payload = verify_with_jwks(params[:token])
  email   = payload.fetch('email')

  user = find_user_by_email(email)

  if user
    user.identities.find_or_create_by!(provider: PROVIDER, uid: payload['sub']) do |identity|
      identity.info = { email: email, name: payload['name'] }
    end
  else
    user = find_or_create_user_from_oauth(
      provider: PROVIDER,
      uid:      payload['sub'],
      info:     { email: email, first_name: payload['given_name'], last_name: payload['family_name'] }
    )
  end

  success(user)
end
Only link by email if your IdP guarantees email_verified. Silent linking against an unverified email is a known account-takeover vector: an attacker registers victim@example.com at the IdP without proving ownership, then logs into Spree as the real victim. Check the email_verified claim (or equivalent) before linking, and reject otherwise.

Logout

POST /api/v3/store/auth/logout
Content-Type: application/json

{ "refresh_token": "rt_xxxxxxxxxxxx" }
This revokes the Spree refresh token. The Spree JWT itself remains valid until it expires naturally (short-lived by design — default 1 hour). Spree does not call the IdP’s revocation endpoint; if you need single sign-out, do that from the client.

Security Notes

A few things worth getting right:
  • Don’t try to pass the third-party JWT through to protected endpoints. Spree’s JwtAuthentication concern verifies iss: 'spree' and aud: 'store_api' with HS256 against the Spree secret — a foreign RS256 token will never validate, and you don’t want it to. The exchange-at-login model is the right one.
  • JWKS caching and rotation. Cache the JWKS (the example uses a 1-hour TTL) but make sure your loader honors the invalidate: true option so that an unrecognized kid triggers a refetch. Otherwise key rotation at the IdP locks users out for up to the TTL.
  • Validate iss and aud claims. Always. The example passes verify_iss: true, verify_aud: true to JWT.decode — don’t drop those.
  • Algorithm pinning. Hard-code algorithms: ['RS256'] (or whatever your IdP uses). Never let the token’s own alg header decide — the classic alg: none and HS-as-RS confusion attacks both exploit lax algorithm selection.
  • Rate limiting. POST /auth/login is rate-limited per IP via Spree::Api::Config[:rate_limit_login]. Tune it in your app config if needed — the same limit applies to email/password and provider-dispatched logins.

Testing

A strategy is a plain Ruby class — test it in isolation without booting a controller:
spec/models/my_app/auth/external_jwt_strategy_spec.rb
require 'rails_helper'

RSpec.describe MyApp::Auth::ExternalJwtStrategy do
  let(:rsa_private) { OpenSSL::PKey::RSA.generate(2048) }
  let(:jwks)        { { keys: [JWT::JWK.new(rsa_private).export] } }

  let(:token) do
    JWT.encode(
      { sub: 'idp-user-123', email: 'alice@example.com', iss: 'https://idp.example', aud: 'spree' },
      rsa_private, 'RS256'
    )
  end

  before do
    stub_request(:get, ENV['EXTERNAL_IDP_JWKS_URL']).to_return(body: jwks.to_json)
  end

  subject(:result) do
    described_class.new(
      params:      { provider: 'external_idp', token: token },
      request_env: {}
    ).authenticate
  end

  it 'provisions a Spree user on first login' do
    expect { result }.to change(Spree.user_class, :count).by(1)
    expect(result).to be_success
    expect(result.value.email).to eq('alice@example.com')
  end

  it 'reuses the user on subsequent logins' do
    described_class.new(params: { token: token }, request_env: {}).authenticate
    expect { result }.not_to change(Spree.user_class, :count)
  end

  it 'fails on an expired token' do
    expired = JWT.encode({ sub: 'x', exp: 1.hour.ago.to_i, iss: 'https://idp.example', aud: 'spree' }, rsa_private, 'RS256')
    result  = described_class.new(params: { token: expired }, request_env: {}).authenticate
    expect(result).not_to be_success
  end
end

Reference

  • Spree::Authentication::Strategies::BaseStrategyspree/core/app/models/spree/authentication/strategies/base_strategy.rb
  • Spree::UserIdentityspree/core/app/models/spree/user_identity.rb
  • Spree::Api::V3::Store::AuthControllerspree/api/app/controllers/spree/api/v3/store/auth_controller.rb
  • Spree::Api::V3::JwtAuthenticationspree/api/app/controllers/concerns/spree/api/v3/jwt_authentication.rb
  • See also: Authentication for Spree.user_class integration.