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

# Integrate a Third-Party Identity Provider

> Step-by-step guide to plugging a custom identity provider (Auth0, Okta, Firebase, Cognito, or any JWT issuer) into the Spree Store and Admin APIs.

export const Since = ({version, from}) => {
  const knownPrevious = {
    '5.0': '4.10',
    '6.0': '5.4'
  };
  const previous = (from ?? knownPrevious[version]) ?? (() => {
    const [major, minor] = version.split('.').map(Number);
    if (Number.isNaN(major) || Number.isNaN(minor) || minor < 1) {
      throw new Error(`<Since version="${version}" />: cannot derive previous version automatically. ` + `Pass an explicit "from" prop, e.g. <Since version="${version}" from="X.Y" />.`);
    }
    return `${major}.${minor - 1}`;
  })();
  return <Tooltip tip={`Available since Spree ${version}+.`} cta="Upgrade instructions" href={`/developer/upgrades/${previous}-to-${version}`}>
      <Badge icon="lock">Spree {version}+</Badge>
    </Tooltip>;
};

<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)                       |
| ---------------------- | ------------------------------------------------- | --------------------------------------- |
| Registry               | `Spree.store_authentication_strategies`           | `Spree.admin_authentication_strategies` |
| User class             | `Spree.user_class`                                | `Spree.admin_user_class`                |
| Login endpoint         | `POST /api/v3/store/auth/login`                   | `POST /api/v3/admin/auth/login`         |
| Refresh endpoint       | `POST /api/v3/store/auth/refresh`                 | `POST /api/v3/admin/auth/refresh`       |
| JWT audience           | `aud: store_api`                                  | `aud: admin_api`                        |
| Refresh token delivery | returned in the response body                     | set as an `HttpOnly` cookie             |
| Strategy base class    | `Spree::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 app/models/my_app/auth/external_jwt_strategy.rb theme={"theme":"night-owl"}
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:

| Helper                                                               | Purpose                                                                           |
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `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_class`                                | Reader 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 serves** — `store_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 Store API theme={"theme":"night-owl"}
  # config/initializers/spree.rb
  Rails.application.config.after_initialize do
    Spree.store_authentication_strategies.add(:external_idp, MyApp::Auth::ExternalJwtStrategy)
  end
  ```

  ```ruby Admin API theme={"theme":"night-owl"}
  # config/initializers/spree.rb
  Rails.application.config.after_initialize do
    Spree.admin_authentication_strategies.add(:okta, MyApp::Auth::OktaStrategy)
  end
  ```

  ```ruby Both theme={"theme":"night-owl"}
  # 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:

| Method                                        | Purpose                                                                                                                     |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `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_h` | Standard 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 Store API theme={"theme":"night-owl"}
  POST /api/v3/store/auth/login
  X-Spree-API-Key: pk_your_publishable_key
  Content-Type: application/json

  {
    "provider": "external_idp",
    "token":    "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIs..."
  }
  ```

  ```http Admin API theme={"theme":"night-owl"}
  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 Store API theme={"theme":"night-owl"}
  {
    "token":         "<Spree HS256 JWT, aud=store_api>",
    "refresh_token": "rt_xxxxxxxxxxxx",
    "user":          { "id": "user_...", "email": "...", "...": "..." }
  }
  ```

  ```json Admin API theme={"theme":"night-owl"}
  {
    "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](/api-reference/admin-api/authentication)).

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

<CodeGroup>
  ```ts Store SDK theme={"theme":"night-owl"}
  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 Admin SDK theme={"theme":"night-owl"}
  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 theme={"theme":"night-owl"}
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 `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.
</Warning>

## Logout

<CodeGroup>
  ```http Store API theme={"theme":"night-owl"}
  POST /api/v3/store/auth/logout
  Content-Type: application/json

  { "refresh_token": "rt_xxxxxxxxxxxx" }
  ```

  ```http Admin API theme={"theme":"night-owl"}
  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 spec/models/my_app/auth/external_jwt_strategy_spec.rb theme={"theme":"night-owl"}
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::BaseStrategy` — `spree/core/app/models/spree/authentication/strategies/base_strategy.rb`
* `Spree::UserIdentity` — `spree/core/app/models/spree/user_identity.rb`
* `Spree::Api::V3::Store::AuthController` — `spree/api/app/controllers/spree/api/v3/store/auth_controller.rb`
* `Spree::Api::V3::Admin::AuthController` — `spree/api/app/controllers/spree/api/v3/admin/auth_controller.rb`
* `Spree::Api::V3::JwtAuthentication` — `spree/api/app/controllers/concerns/spree/api/v3/jwt_authentication.rb`
* See also: [Staff & Roles](/developer/core-concepts/staff-roles) for the admin login flow, [Customers](/developer/core-concepts/customers) for storefront auth, and [Authentication](/developer/customization/authentication) for `Spree.user_class` integration.
