@spree/docs 0.1.68 → 0.1.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,301 @@
1
+ ---
2
+ title: Integrate a Third-Party Identity Provider
3
+ description: Step-by-step guide to plugging a custom identity provider (Auth0, Okta, Firebase, Cognito, or any JWT issuer) into the Spree Store API.
4
+ ---
5
+
6
+ import { Since } from '/snippets/since.mdx';
7
+
8
+
9
+ ## Overview
10
+
11
+ 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.
12
+
13
+ By the end of this guide you'll have:
14
+
15
+ - A custom strategy that verifies a third-party JWT against a JWKS endpoint
16
+ - A user account auto-provisioned on first login, reused on subsequent logins
17
+ - A standard **Spree-issued JWT + refresh token** returned to the client
18
+ - All Store API endpoints (cart, checkout, account) protected by Spree's own JWT — the third-party token is only used at the exchange step
19
+
20
+ > **INFO:** 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.
21
+
22
+ ## Architecture
23
+
24
+ ```
25
+ Client → POST /api/v3/store/auth/login
26
+ { provider: "my_idp", token: "<third-party JWT>" }
27
+
28
+
29
+ AuthController looks up the strategy by `provider` key
30
+
31
+
32
+ YourStrategy#authenticate
33
+ • verifies the JWT against the IdP's JWKS
34
+ • finds or creates a Spree::UserIdentity → Spree user
35
+ • returns success(user) or failure(message)
36
+
37
+
38
+ Spree issues its own JWT (HS256, iss=spree, aud=store_api)
39
+ + a rotatable RefreshToken
40
+
41
+
42
+ Client → subsequent calls with `Authorization: Bearer <Spree JWT>`
43
+ ```
44
+
45
+ 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.
46
+
47
+ ## Step 1: Create the Strategy Class
48
+
49
+ Subclass `Spree::Authentication::Strategies::BaseStrategy` and implement two methods: `provider` (a string identifier) and `authenticate` (returns a `Spree::ServiceModule::Result`).
50
+
51
+ ```ruby app/models/my_app/auth/external_jwt_strategy.rb
52
+ module MyApp
53
+ module Auth
54
+ class ExternalJwtStrategy < Spree::Authentication::Strategies::BaseStrategy
55
+ PROVIDER = 'external_idp'.freeze
56
+
57
+ def provider
58
+ PROVIDER
59
+ end
60
+
61
+ def authenticate
62
+ token = params[:token] || extract_bearer
63
+ return failure(I18n.t('spree.api.unauthorized')) if token.blank?
64
+
65
+ payload = verify_with_jwks(token)
66
+
67
+ user = find_or_create_user_from_oauth(
68
+ provider: PROVIDER,
69
+ uid: payload.fetch('sub'),
70
+ info: {
71
+ email: payload['email'],
72
+ first_name: payload['given_name'],
73
+ last_name: payload['family_name']
74
+ }
75
+ )
76
+
77
+ success(user)
78
+ rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::InvalidIssuerError, JWT::InvalidAudError, KeyError => e
79
+ failure(e.message)
80
+ end
81
+
82
+ private
83
+
84
+ def verify_with_jwks(token)
85
+ jwks_loader = ->(opts) { jwks(force: opts[:invalidate]) }
86
+
87
+ JWT.decode(
88
+ token, nil, true,
89
+ algorithms: ['RS256'],
90
+ iss: ENV.fetch('EXTERNAL_IDP_ISSUER'),
91
+ aud: ENV.fetch('EXTERNAL_IDP_AUDIENCE'),
92
+ verify_iss: true,
93
+ verify_aud: true,
94
+ jwks: jwks_loader
95
+ ).first
96
+ end
97
+
98
+ def jwks(force: false)
99
+ Rails.cache.fetch('external_idp:jwks', expires_in: 1.hour, force: force) do
100
+ uri = URI(ENV.fetch('EXTERNAL_IDP_JWKS_URL'))
101
+ JSON.parse(Net::HTTP.get(uri))
102
+ end
103
+ end
104
+
105
+ def extract_bearer
106
+ header = request_env['HTTP_AUTHORIZATION'].to_s
107
+ header.start_with?('Bearer ') ? header.split(' ', 2).last : nil
108
+ end
109
+ end
110
+ end
111
+ end
112
+ ```
113
+
114
+ ### What the base class gives you
115
+
116
+ `Spree::Authentication::Strategies::BaseStrategy` (in `spree_core`) exposes a few helpers so your subclass stays small:
117
+
118
+ | Helper | Purpose |
119
+ |---|---|
120
+ | `success(user)` | Wrap a user in a successful `ServiceModule::Result` |
121
+ | `failure(message)` | Wrap an error message in a failed result |
122
+ | `find_user_by_email(email)` | Lookup against `Spree.user_class` |
123
+ | `find_or_create_user_from_oauth(provider:, uid:, info:, tokens: {})` | Calls `Spree::UserIdentity.find_or_create_from_oauth` with the right `user_class` |
124
+ | `params`, `request_env`, `user_class` | Reader access to the controller-supplied inputs |
125
+
126
+ `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.
127
+
128
+ ## Step 2: Register the Strategy
129
+
130
+ 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.
131
+
132
+ ```ruby config/initializers/spree.rb
133
+ Rails.application.config.after_initialize do
134
+ Spree.store_authentication_strategies.add(:external_idp, MyApp::Auth::ExternalJwtStrategy)
135
+ end
136
+ ```
137
+
138
+ `Spree.store_authentication_strategies` is a `Spree::Authentication::StrategyRegistry`. The full API:
139
+
140
+ | Method | Purpose |
141
+ |---|---|
142
+ | `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. |
143
+ | `remove(key)` | Unregister a strategy. Idempotent (returns `nil` if the key was never registered). |
144
+ | `[key]` | Look up a registered class. |
145
+ | `key?(key)`, `keys`, `values`, `each`, `to_h` | Standard introspection. |
146
+
147
+ > **WARNING:** `Spree::UserIdentity` validates that `provider` is a registered strategy key. Registration must happen during boot — before the first login attempt.
148
+
149
+ For an admin-side equivalent, register under `Spree.admin_authentication_strategies` instead and instantiate your strategy against `Spree.admin_user_class`:
150
+
151
+ ```ruby
152
+ Spree.admin_authentication_strategies.add(:okta, MyApp::Auth::OktaStrategy)
153
+ ```
154
+
155
+ ## Step 3: Call the Exchange Endpoint
156
+
157
+ `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`.
158
+
159
+ ```http
160
+ POST /api/v3/store/auth/login
161
+ X-Spree-API-Key: pk_your_publishable_key
162
+ Content-Type: application/json
163
+
164
+ {
165
+ "provider": "external_idp",
166
+ "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIs..."
167
+ }
168
+ ```
169
+
170
+ The response is the standard Spree auth payload:
171
+
172
+ ```json
173
+ {
174
+ "token": "<Spree HS256 JWT, aud=store_api>",
175
+ "refresh_token": "rt_xxxxxxxxxxxx",
176
+ "user": { "id": "user_...", "email": "...", "first_name": "...", "...": "..." }
177
+ }
178
+ ```
179
+
180
+ 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.
181
+
182
+ From `@spree/sdk` the same call looks like:
183
+
184
+ ```ts
185
+ import { createClient } from '@spree/sdk'
186
+
187
+ const client = createClient({ baseUrl: 'https://your-store.com', publishableKey: '<pk>' })
188
+
189
+ const auth = await client.auth.login({
190
+ provider: 'external_idp',
191
+ token: thirdPartyJwt,
192
+ })
193
+ ```
194
+
195
+ `LoginCredentials` is a discriminated union — pass `{ email, password }` for the built-in strategy, or `{ provider, ...customFields }` for any strategy you registered.
196
+
197
+ ## Account Linking
198
+
199
+ 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:
200
+
201
+ ```ruby
202
+ def authenticate
203
+ payload = verify_with_jwks(params[:token])
204
+ email = payload.fetch('email')
205
+
206
+ user = find_user_by_email(email)
207
+
208
+ if user
209
+ user.identities.find_or_create_by!(provider: PROVIDER, uid: payload['sub']) do |identity|
210
+ identity.info = { email: email, name: payload['name'] }
211
+ end
212
+ else
213
+ user = find_or_create_user_from_oauth(
214
+ provider: PROVIDER,
215
+ uid: payload['sub'],
216
+ info: { email: email, first_name: payload['given_name'], last_name: payload['family_name'] }
217
+ )
218
+ end
219
+
220
+ success(user)
221
+ end
222
+ ```
223
+
224
+ > **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.
225
+
226
+ ## Logout
227
+
228
+ ```http
229
+ POST /api/v3/store/auth/logout
230
+ Content-Type: application/json
231
+
232
+ { "refresh_token": "rt_xxxxxxxxxxxx" }
233
+ ```
234
+
235
+ 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.
236
+
237
+ ## Security Notes
238
+
239
+ A few things worth getting right:
240
+
241
+ - **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.
242
+ - **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.
243
+ - **Validate `iss` and `aud` claims.** Always. The example passes `verify_iss: true, verify_aud: true` to `JWT.decode` — don't drop those.
244
+ - **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.
245
+ - **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.
246
+
247
+ ## Testing
248
+
249
+ A strategy is a plain Ruby class — test it in isolation without booting a controller:
250
+
251
+ ```ruby spec/models/my_app/auth/external_jwt_strategy_spec.rb
252
+ require 'rails_helper'
253
+
254
+ RSpec.describe MyApp::Auth::ExternalJwtStrategy do
255
+ let(:rsa_private) { OpenSSL::PKey::RSA.generate(2048) }
256
+ let(:jwks) { { keys: [JWT::JWK.new(rsa_private).export] } }
257
+
258
+ let(:token) do
259
+ JWT.encode(
260
+ { sub: 'idp-user-123', email: 'alice@example.com', iss: 'https://idp.example', aud: 'spree' },
261
+ rsa_private, 'RS256'
262
+ )
263
+ end
264
+
265
+ before do
266
+ stub_request(:get, ENV['EXTERNAL_IDP_JWKS_URL']).to_return(body: jwks.to_json)
267
+ end
268
+
269
+ subject(:result) do
270
+ described_class.new(
271
+ params: { provider: 'external_idp', token: token },
272
+ request_env: {}
273
+ ).authenticate
274
+ end
275
+
276
+ it 'provisions a Spree user on first login' do
277
+ expect { result }.to change(Spree.user_class, :count).by(1)
278
+ expect(result).to be_success
279
+ expect(result.value.email).to eq('alice@example.com')
280
+ end
281
+
282
+ it 'reuses the user on subsequent logins' do
283
+ described_class.new(params: { token: token }, request_env: {}).authenticate
284
+ expect { result }.not_to change(Spree.user_class, :count)
285
+ end
286
+
287
+ it 'fails on an expired token' do
288
+ expired = JWT.encode({ sub: 'x', exp: 1.hour.ago.to_i, iss: 'https://idp.example', aud: 'spree' }, rsa_private, 'RS256')
289
+ result = described_class.new(params: { token: expired }, request_env: {}).authenticate
290
+ expect(result).not_to be_success
291
+ end
292
+ end
293
+ ```
294
+
295
+ ## Reference
296
+
297
+ - `Spree::Authentication::Strategies::BaseStrategy` — `spree/core/app/models/spree/authentication/strategies/base_strategy.rb`
298
+ - `Spree::UserIdentity` — `spree/core/app/models/spree/user_identity.rb`
299
+ - `Spree::Api::V3::Store::AuthController` — `spree/api/app/controllers/spree/api/v3/store/auth_controller.rb`
300
+ - `Spree::Api::V3::JwtAuthentication` — `spree/api/app/controllers/concerns/spree/api/v3/jwt_authentication.rb`
301
+ - See also: [Authentication](../customization/authentication.md) for `Spree.user_class` integration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spree/docs",
3
- "version": "0.1.68",
3
+ "version": "0.1.69",
4
4
  "description": "Spree Commerce developer documentation for AI agents and local reference",
5
5
  "type": "module",
6
6
  "license": "CC-BY-4.0",