Back to Spree

Integrate a Third-Party Identity Provider

docs/developer/how-to/custom-api-authentication.mdx

5.5.016.1 KB
Original Source

import { Since } from '/snippets/since.mdx';

<Since version="5.5" />

Overview

Both Spree APIs — the customer-facing Store API and the back-office Admin API — ship with the same pluggable authentication system. You register a strategy class for a named provider, and the existing login endpoints 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
  • Every API endpoint protected by Spree's own JWT — the third-party token is only used at the login exchange step

Store vs Admin — what changes

The mechanism is identical for both surfaces. A strategy you write works on either side; only the registry you add it to (and a handful of surface-specific details) differ:

Store API (customers)Admin API (staff)
RegistrySpree.store_authentication_strategiesSpree.admin_authentication_strategies
User classSpree.user_classSpree.admin_user_class
Login endpointPOST /api/v3/store/auth/loginPOST /api/v3/admin/auth/login
Refresh endpointPOST /api/v3/store/auth/refreshPOST /api/v3/admin/auth/refresh
JWT audienceaud: store_apiaud: admin_api
Refresh token deliveryreturned in the response bodyset as an HttpOnly cookie
Strategy base classSpree::Authentication::Strategies::BaseStrategy(same)

Everything else — the strategy class you write, the BaseStrategy helpers, JWT verification, account provisioning, account linking — is the same. The walkthrough below uses the Store API; each step calls out the one-line Admin swap.

Architecture

The flow below uses the Store API; the Admin API is identical with admin in the path and aud: admin_api on the issued JWT.

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).

ruby
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(Spree.t('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(Spree.t('api.unauthorized'))
      end

      private

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

        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 a registry in an initializer. This is the only line that decides which API the provider servesstore_authentication_strategies for customer login, admin_authentication_strategies for staff login. Register with both to allow the same provider on either surface. The key you choose here is what clients send as provider in the login payload.

<CodeGroup>
ruby
# config/initializers/spree.rb
Rails.application.config.after_initialize do
  Spree.store_authentication_strategies.add(:external_idp, MyApp::Auth::ExternalJwtStrategy)
end
ruby
# config/initializers/spree.rb
Rails.application.config.after_initialize do
  Spree.admin_authentication_strategies.add(:okta, MyApp::Auth::OktaStrategy)
end
ruby
# config/initializers/spree.rb
Rails.application.config.after_initialize do
  Spree.store_authentication_strategies.add(:external_idp, MyApp::Auth::ExternalJwtStrategy)
  Spree.admin_authentication_strategies.add(:external_idp, MyApp::Auth::ExternalJwtStrategy)
end
</CodeGroup>

Both registries are a Spree::Authentication::StrategyRegistry with the same 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.

The strategy is instantiated with the surface's user class automatically — Spree.user_class from the store registry, Spree.admin_user_class from the admin registry — and your authenticate reads it via the user_class helper, so the same class provisions customers on one side and staff on the other.

<Tip> Restrict a surface to SSO by removing the built-in email/password strategy after adding yours: `Spree.admin_authentication_strategies.remove(:email)` locks staff to your provider; the store side stays on email/password. </Tip> <Warning> `Spree::UserIdentity` validates that `provider` is a registered strategy key. Registration must happen during boot — before the first login attempt. </Warning>

Step 3: Call the Exchange Endpoint

The login endpoint is the single dispatcher — /api/v3/store/auth/login for customers, /api/v3/admin/auth/login for staff. 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.

<CodeGroup>
http
POST /api/v3/store/auth/login
X-Spree-API-Key: pk_your_publishable_key
Content-Type: application/json

{
  "provider": "external_idp",
  "token":    "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIs..."
}
http
POST /api/v3/admin/auth/login
X-Spree-API-Key: sk_your_secret_key
Content-Type: application/json

{
  "provider": "okta",
  "token":    "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIs..."
}
</CodeGroup>

The response is the standard Spree auth payload. The difference is the refresh token: the Store API returns it in the body, while the Admin API sets it as an HttpOnly cookie scoped to /api/v3/admin/auth and omits it from the body.

<CodeGroup>
json
{
  "token":         "<Spree HS256 JWT, aud=store_api>",
  "refresh_token": "rt_xxxxxxxxxxxx",
  "user":          { "id": "user_...", "email": "...", "...": "..." }
}
json
{
  "token": "<Spree HS256 JWT, aud=admin_api>",
  "user":  { "id": "user_...", "email": "...", "...": "..." }
}

The refresh token arrives as a Set-Cookie: spree_admin_refresh_token=…; HttpOnly; Path=/api/v3/admin/auth response header rather than in the body.

</CodeGroup>

From here, the client sends Authorization: Bearer <Spree JWT> on every subsequent call. When the JWT expires (default: 1 hour), it rotates via the refresh endpoint — the Store API takes the refresh token in the body of POST /api/v3/store/auth/refresh; the Admin API drives POST /api/v3/admin/auth/refresh entirely from the cookie (see Admin Auth & Cookie Refresh).

The SDKs wrap the same exchange — @spree/sdk for the Store API, @spree/admin-sdk for the Admin API:

<CodeGroup>
ts
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,
})
ts
import { createAdminClient } from '@spree/admin-sdk'

const client = createAdminClient({ baseUrl: 'https://your-store.com' })

// Refresh token lands in the HttpOnly cookie; only the access token comes back here.
const auth = await client.auth.login({
  provider: 'okta',
  token:    thirdPartyJwt,
})
</CodeGroup>

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:

ruby
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
<Warning> **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 `[email protected]` 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. </Warning>

Logout

<CodeGroup>
http
POST /api/v3/store/auth/logout
Content-Type: application/json

{ "refresh_token": "rt_xxxxxxxxxxxx" }
http
POST /api/v3/admin/auth/logout
Content-Type: application/json

# No body — the refresh token is read from the HttpOnly cookie and cleared.
</CodeGroup>

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 the expected audience (store_api or admin_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 kid_not_found: 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:

ruby
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: '[email protected]', 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('[email protected]')
  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::Admin::AuthControllerspree/api/app/controllers/spree/api/v3/admin/auth_controller.rb
  • Spree::Api::V3::JwtAuthenticationspree/api/app/controllers/concerns/spree/api/v3/jwt_authentication.rb
  • See also: Staff & Roles for the admin login flow, Customers for storefront auth, and Authentication for Spree.user_class integration.