shopify_app 22.0.1 → 22.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/rubocop.yml +1 -2
- data/.rubocop.yml +0 -1
- data/CHANGELOG.md +30 -0
- data/Gemfile.lock +20 -17
- data/README.md +38 -0
- data/app/controllers/concerns/shopify_app/ensure_has_session.rb +11 -5
- data/app/controllers/concerns/shopify_app/ensure_installed.rb +8 -2
- data/app/controllers/concerns/shopify_app/shop_access_scopes_verification.rb +5 -1
- data/app/controllers/shopify_app/callback_controller.rb +10 -1
- data/app/controllers/shopify_app/sessions_controller.rb +24 -4
- data/app/views/shopify_app/layouts/app_bridge.html.erb +17 -0
- data/app/views/shopify_app/sessions/patch_shopify_id_token.html.erb +0 -0
- data/config/routes.rb +1 -0
- data/docs/Troubleshooting.md +0 -23
- data/docs/Upgrading.md +25 -0
- data/docs/shopify_app/authentication.md +105 -20
- data/docs/shopify_app/sessions.md +110 -14
- data/docs/shopify_app/webhooks.md +1 -1
- data/lib/generators/shopify_app/install/templates/shopify_app.rb.tt +2 -0
- data/lib/shopify_app/admin_api/with_token_refetch.rb +28 -0
- data/lib/shopify_app/auth/post_authenticate_tasks.rb +48 -0
- data/lib/shopify_app/auth/token_exchange.rb +73 -0
- data/lib/shopify_app/configuration.rb +54 -3
- data/lib/shopify_app/controller_concerns/app_proxy_verification.rb +1 -1
- data/lib/shopify_app/controller_concerns/embedded_app.rb +27 -0
- data/lib/shopify_app/controller_concerns/ensure_billing.rb +11 -3
- data/lib/shopify_app/controller_concerns/login_protection.rb +8 -23
- data/lib/shopify_app/controller_concerns/redirect_for_embedded.rb +5 -0
- data/lib/shopify_app/controller_concerns/token_exchange.rb +111 -0
- data/lib/shopify_app/controller_concerns/with_shopify_id_token.rb +41 -0
- data/lib/shopify_app/middleware/jwt_middleware.rb +13 -9
- data/lib/shopify_app/session/jwt.rb +9 -0
- data/lib/shopify_app/version.rb +1 -1
- data/lib/shopify_app.rb +9 -0
- data/package.json +1 -1
- data/shopify_app.gemspec +1 -1
- data/yarn.lock +3 -3
- metadata +12 -5
@@ -3,12 +3,11 @@
|
|
3
3
|
Sessions are used to make contextual API calls for either a shop (offline session) or a user (online session). This gem has ownership of session persistence.
|
4
4
|
|
5
5
|
#### Table of contents
|
6
|
-
|
7
6
|
- [Sessions](#sessions)
|
8
7
|
- [Table of contents](#table-of-contents)
|
9
8
|
- [Sessions](#sessions-1)
|
10
|
-
- [Types of
|
11
|
-
- [
|
9
|
+
- [Types of access tokens (sessions)](#types-of-access-tokens-sessions)
|
10
|
+
- [Access token storage (session)](#access-token-storage-session)
|
12
11
|
- [Shop (offline) token storage](#shop-offline-token-storage)
|
13
12
|
- [User (online) token storage](#user-online-token-storage)
|
14
13
|
- [In-memory Session Storage for testing](#in-memory-session-storage-for-testing)
|
@@ -20,6 +19,7 @@ Sessions are used to make contextual API calls for either a shop (offline sessio
|
|
20
19
|
- [**Shop Sessions - `EnsureInstalled`**](#shop-sessions---ensureinstalled)
|
21
20
|
- [User Sessions - `EnsureHasSession`](#user-sessions---ensurehassession)
|
22
21
|
- [Getting sessions from a Shop or User model record - 'with\_shopify\_session'](#getting-sessions-from-a-shop-or-user-model-record---with_shopify_session)
|
22
|
+
- [Re-fetching an access token when API returns Unauthorized](#re-fetching-an-access-token-when-api-returns-unauthorized)
|
23
23
|
- [Access scopes](#access-scopes)
|
24
24
|
- [`ShopifyApp::ShopSessionStorageWithScopes`](#shopifyappshopsessionstoragewithscopes)
|
25
25
|
- [`ShopifyApp::UserSessionStorageWithScopes`](#shopifyappusersessionstoragewithscopes)
|
@@ -27,18 +27,16 @@ Sessions are used to make contextual API calls for either a shop (offline sessio
|
|
27
27
|
- [Migrating from `ShopifyApi::Auth::SessionStorage` to `ShopifyApp::SessionStorage`](#migrating-from-shopifyapiauthsessionstorage-to-shopifyappsessionstorage)
|
28
28
|
|
29
29
|
## Sessions
|
30
|
-
#### Types of
|
31
|
-
- **Shop** ([offline access](https://shopify.dev/docs/apps/auth/
|
30
|
+
#### Types of access tokens (sessions)
|
31
|
+
- **Shop** ([offline access](https://shopify.dev/docs/apps/auth/access-token-types/offline))
|
32
32
|
- Access token is linked to the store
|
33
33
|
- Meant for long-term access to a store, where no user interaction is involved
|
34
34
|
- Ideal for background jobs or maintenance work
|
35
|
-
- **User** ([online access](https://shopify.dev/docs/apps/auth/
|
35
|
+
- **User** ([online access](https://shopify.dev/docs/apps/auth/access-token-types/online))
|
36
36
|
- Access token is linked to an individual user on a store
|
37
37
|
- Meant to be used when a user is interacting with your app through the web
|
38
38
|
|
39
|
-
|
40
|
-
|
41
|
-
#### Session token storage
|
39
|
+
#### Access token storage (session)
|
42
40
|
##### Shop (offline) token storage
|
43
41
|
⚠️ All apps must have a shop session storage, if you started from the [Ruby App Template](https://github.com/Shopify/shopify-app-template-ruby), it's already configured to have a Shop model by default.
|
44
42
|
|
@@ -50,7 +48,7 @@ If you don't already have a repository to store the access tokens:
|
|
50
48
|
rails generate shopify_app:shop_model
|
51
49
|
```
|
52
50
|
|
53
|
-
2. Configure `config/initializers/shopify_app.rb` to enable shop
|
51
|
+
2. Configure `config/initializers/shopify_app.rb` to enable shop access token persistance:
|
54
52
|
|
55
53
|
```ruby
|
56
54
|
config.shop_session_repository = 'Shop'
|
@@ -66,7 +64,7 @@ If your app has user interactions and would like to control permission based on
|
|
66
64
|
rails generate shopify_app:user_model
|
67
65
|
```
|
68
66
|
|
69
|
-
2. Configure `config/initializers/shopify_app.rb` to enable user
|
67
|
+
2. Configure `config/initializers/shopify_app.rb` to enable user access token persistance:
|
70
68
|
|
71
69
|
```ruby
|
72
70
|
config.user_session_repository = 'User'
|
@@ -74,6 +72,8 @@ config.user_session_repository = 'User'
|
|
74
72
|
|
75
73
|
The current Shopify user will be stored in the rails session at `session[:shopify_user]`
|
76
74
|
|
75
|
+
You should also enable the [check for session expiry](#expiry-date) so that a new access token can be fetched before being used for an API operation.
|
76
|
+
|
77
77
|
##### In-memory Session Storage for testing
|
78
78
|
The `ShopifyApp` gem includes methods for in-memory storage for both shop and user sessions. In-memory storage is intended to be used in a testing environment, please use a persistent storage for your application.
|
79
79
|
- [InMemoryShopSessionStore](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/in_memory_shop_session_store.rb)
|
@@ -152,7 +152,7 @@ class MyController < ApplicationController
|
|
152
152
|
|
153
153
|
client = ShopifyAPI::Clients::Graphql::Admin.new(session: current_session)
|
154
154
|
client.query(
|
155
|
-
|
155
|
+
# ...
|
156
156
|
)
|
157
157
|
end
|
158
158
|
end
|
@@ -171,7 +171,7 @@ class MyController < ApplicationController
|
|
171
171
|
|
172
172
|
client = ShopifyAPI::Clients::Graphql::Admin.new(session: current_session)
|
173
173
|
client.query(
|
174
|
-
|
174
|
+
# ...
|
175
175
|
)
|
176
176
|
end
|
177
177
|
end
|
@@ -203,6 +203,100 @@ user.with_shopify_session do
|
|
203
203
|
end
|
204
204
|
```
|
205
205
|
|
206
|
+
#### Re-fetching an access token when API returns Unauthorized
|
207
|
+
|
208
|
+
When using `ShopifyApp::EnsureHasSession` and the `new_embedded_auth_strategy` configuration, any **unhandled** Unauthorized `ShopifyAPI::Errors::HttpResponseError` will cause the app to perform token exchange to fetch a new access token from Shopify and the action to be executed again. This will update and store the new access token to the current session instance.
|
209
|
+
|
210
|
+
```ruby
|
211
|
+
class MyController < ApplicationController
|
212
|
+
include ShopifyApp::EnsureHasSession
|
213
|
+
|
214
|
+
def index
|
215
|
+
client = ShopifyAPI::Clients::Graphql::Admin.new(session: current_shopify_session)
|
216
|
+
|
217
|
+
# If this call raises an Unauthorized error from Shopify, EnsureHasSession
|
218
|
+
# will execute the action again after performing token exchange.
|
219
|
+
# It will store and use the newly retrieved access token for this and any subsequent calls.
|
220
|
+
client.query(options)
|
221
|
+
end
|
222
|
+
end
|
223
|
+
```
|
224
|
+
|
225
|
+
If the error is being rescued in the action, it's still possible to make use of `with_token_refetch` provided by `EnsureHasSession` so that a new access token is fetched and the code is executed again with it. This will also update the session parameter with the new attributes.
|
226
|
+
This block should be used to wrap the code that makes API queries, so your business logic won't be retried.
|
227
|
+
|
228
|
+
```ruby
|
229
|
+
class MyController < ApplicationController
|
230
|
+
include ShopifyApp::EnsureHasSession
|
231
|
+
|
232
|
+
def index
|
233
|
+
# Your app's business logic
|
234
|
+
with_token_refetch(current_shopify_session, shopify_id_token) do
|
235
|
+
# Unauthorized errors raised within this block will initiate token exchange.
|
236
|
+
# `with_token_refetch` will store the new access token and use it
|
237
|
+
# to execute this block again.
|
238
|
+
# Any subsequent calls using the same session instance will have the new token.
|
239
|
+
client = ShopifyAPI::Clients::Graphql::Admin.new(session: current_shopify_session)
|
240
|
+
client.query(options)
|
241
|
+
end
|
242
|
+
# Your app's business logic continues
|
243
|
+
rescue => error
|
244
|
+
# app's specific error handling
|
245
|
+
end
|
246
|
+
end
|
247
|
+
```
|
248
|
+
|
249
|
+
It's also possible to use `with_token_refetch` on classes other than the controller by including the `ShopifyApp::AdminAPI::WithTokenRefetch` module and passing in the session along with the current request's `shopify_id_token`, which is provided by `ShopifyApp::EnsureHasSession`. This will also update the session parameter with the new attributes.
|
250
|
+
|
251
|
+
```ruby
|
252
|
+
# my_controller.rb
|
253
|
+
class MyController < ApplicationController
|
254
|
+
include ShopifyApp::EnsureHasSession
|
255
|
+
|
256
|
+
def index
|
257
|
+
# shopify_id_token is a method provided by EnsureHasSession
|
258
|
+
MyClass.new.do_things(current_shopify_session, shopify_id_token)
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
# my_class.rb
|
263
|
+
class MyClass
|
264
|
+
include ShopifyApp::AdminAPI::WithTokenRefetch
|
265
|
+
|
266
|
+
def do_things(session, shopify_id_token)
|
267
|
+
with_token_refetch(session, shopify_id_token) do
|
268
|
+
# Unauthorized errors raised within this block will initiate token exchange.
|
269
|
+
# `with_token_refetch` will store the new access token and use it
|
270
|
+
# to execute this block again.
|
271
|
+
# Any subsequent calls using the same session instance will have the new token.
|
272
|
+
client = ShopifyAPI::Clients::Graphql::Admin.new(session: session)
|
273
|
+
client.query(options)
|
274
|
+
end
|
275
|
+
rescue => error
|
276
|
+
# app's specific error handling
|
277
|
+
end
|
278
|
+
end
|
279
|
+
```
|
280
|
+
|
281
|
+
If the retried block raises an `Unauthorized` error again, `with_token_refetch` will delete the current `session` from the database and raise the error again.
|
282
|
+
|
283
|
+
```ruby
|
284
|
+
class MyController < ApplicationController
|
285
|
+
include ShopifyApp::EnsureHasSession
|
286
|
+
|
287
|
+
def index
|
288
|
+
client = ShopifyAPI::Clients::Graphql::Admin.new(session: current_shopify_session)
|
289
|
+
with_token_refetch(current_shopify_session, shopify_id_token) do
|
290
|
+
# When this call raises Unauthorized a second time during retry,
|
291
|
+
# the `session` will be deleted from the database and the error raised
|
292
|
+
client.query(options)
|
293
|
+
end
|
294
|
+
rescue => error
|
295
|
+
# The Unauthorized error will reach this rescue
|
296
|
+
end
|
297
|
+
end
|
298
|
+
```
|
299
|
+
|
206
300
|
## Access scopes
|
207
301
|
If you want to customize how access scopes are stored for shops and users, you can implement the `access_scopes` getters and setters in the models that include `ShopifyApp::ShopSessionStorageWithScopes` and `ShopifyApp::UserSessionStorageWithScopes` as shown:
|
208
302
|
|
@@ -240,6 +334,8 @@ When the configuration flag `check_session_expiry_date` is set to true, the user
|
|
240
334
|
## Migrating from shop-based to user-based token strategy
|
241
335
|
|
242
336
|
1. Run the `user_model` generator as [mentioned above](#user-online-token-storage).
|
337
|
+
- The generator will ask whether you want to migrate the User model to include `access_scopes` and `expires_at` columns. `expires_at` field is useful for detecting when the user session has expired and trigger a re-auth before an operation. It can reduce
|
338
|
+
API failures for invalid access tokens. Configure the [expiry date check](#expiry-date) to complete this feature.
|
243
339
|
2. Ensure that both your `Shop` model and `User` model includes the [necessary concerns](#available-activesupportconcerns-that-contains-implementation-of-the-above-methods)
|
244
340
|
3. Update the configuration file to use the new session storage.
|
245
341
|
|
@@ -255,5 +351,5 @@ config.user_session_repository = {YOUR_USER_MODEL_CLASS}
|
|
255
351
|
- Sessions storage are now handled with [ShopifyApp::SessionRepository](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/session_repository.rb)
|
256
352
|
- To migrate and specify your shop or user session storage method:
|
257
353
|
1. Remove `session_storage` configuration from `config/initializers/shopify_app.rb`
|
258
|
-
2. Follow ["
|
354
|
+
2. Follow ["Access Token Storage" instructions](#access-token-storage-session) to specify the storage repository for shop and user sessions.
|
259
355
|
- [Customizing session storage](#customizing-session-storage-with-shopifyappsessionrepository)
|
@@ -18,7 +18,7 @@ ShopifyApp.configure do |config|
|
|
18
18
|
end
|
19
19
|
```
|
20
20
|
|
21
|
-
When the [OAuth callback](/docs/shopify_app/authentication.md#oauth-callback) is completed successfully, ShopifyApp will queue a background job which will ensure all the specified webhooks exist for that shop. Because this runs on every OAuth callback, it means your app will always have the webhooks it needs even if the user uninstalls and re-installs the app.
|
21
|
+
When the [OAuth callback](/docs/shopify_app/authentication.md#oauth-callback) or token exchange is completed successfully, ShopifyApp will queue a background job which will ensure all the specified webhooks exist for that shop. Because this runs on every OAuth callback, it means your app will always have the webhooks it needs even if the user uninstalls and re-installs the app.
|
22
22
|
|
23
23
|
ShopifyApp also provides a [WebhooksController](/app/controllers/shopify_app/webhooks_controller.rb) that receives webhooks and queues a job based on the received topic. For example, if you register the webhook from above, then all you need to do is create a job called `CartsUpdateJob`. The job will be queued with 2 params: `shop_domain` and `webhook` (which is the webhook body).
|
24
24
|
|
@@ -4,6 +4,8 @@ ShopifyApp.configure do |config|
|
|
4
4
|
config.scope = "<%= @scope %>" # Consult this page for more scope options:
|
5
5
|
# https://help.shopify.com/en/api/getting-started/authentication/oauth/scopes
|
6
6
|
config.embedded_app = <%= embedded_app? %>
|
7
|
+
config.new_embedded_auth_strategy = <%= embedded_app? %>
|
8
|
+
|
7
9
|
config.after_authenticate_job = false
|
8
10
|
config.api_version = "<%= @api_version %>"
|
9
11
|
config.shop_session_repository = 'Shop'
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ShopifyApp
|
4
|
+
module AdminAPI
|
5
|
+
module WithTokenRefetch
|
6
|
+
def with_token_refetch(session, shopify_id_token)
|
7
|
+
retrying = false if retrying.nil?
|
8
|
+
yield
|
9
|
+
rescue ShopifyAPI::Errors::HttpResponseError => error
|
10
|
+
if error.code != 401
|
11
|
+
ShopifyApp::Logger.debug("Encountered error: #{error.code} - #{error.response.inspect}, re-raising")
|
12
|
+
elsif retrying
|
13
|
+
ShopifyApp::Logger.debug("Shopify API returned a 401 Unauthorized error that was not corrected " \
|
14
|
+
"with token exchange, deleting current session and re-raising")
|
15
|
+
ShopifyApp::SessionRepository.delete_session(session.id)
|
16
|
+
else
|
17
|
+
retrying = true
|
18
|
+
ShopifyApp::Logger.debug("Shopify API returned a 401 Unauthorized error, exchanging token and " \
|
19
|
+
"retrying with new session")
|
20
|
+
new_session = ShopifyApp::Auth::TokenExchange.perform(shopify_id_token)
|
21
|
+
session.copy_attributes_from(new_session)
|
22
|
+
retry
|
23
|
+
end
|
24
|
+
raise
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ShopifyApp
|
4
|
+
module Auth
|
5
|
+
class PostAuthenticateTasks
|
6
|
+
class << self
|
7
|
+
def perform(session)
|
8
|
+
ShopifyApp::Logger.debug("Performing post authenticate tasks")
|
9
|
+
# Ensure we use the shop session to install webhooks
|
10
|
+
session_for_shop = session.online? ? shop_session(session) : session
|
11
|
+
|
12
|
+
install_webhooks(session_for_shop)
|
13
|
+
|
14
|
+
perform_after_authenticate_job(session)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def shop_session(session)
|
20
|
+
ShopifyApp::SessionRepository.retrieve_shop_session_by_shopify_domain(session.shop)
|
21
|
+
end
|
22
|
+
|
23
|
+
def install_webhooks(session)
|
24
|
+
ShopifyApp::Logger.debug("PostAuthenticateTasks: Installing webhooks")
|
25
|
+
return unless ShopifyApp.configuration.has_webhooks?
|
26
|
+
|
27
|
+
WebhooksManager.queue(session.shop, session.access_token)
|
28
|
+
end
|
29
|
+
|
30
|
+
def perform_after_authenticate_job(session)
|
31
|
+
ShopifyApp::Logger.debug("PostAuthenticateTasks: Performing after_authenticate_job")
|
32
|
+
config = ShopifyApp.configuration.after_authenticate_job
|
33
|
+
|
34
|
+
return unless config && config[:job].present?
|
35
|
+
|
36
|
+
job = config[:job]
|
37
|
+
job = job.constantize if job.is_a?(String)
|
38
|
+
|
39
|
+
if config[:inline] == true
|
40
|
+
job.perform_now(shop_domain: session.shop)
|
41
|
+
else
|
42
|
+
job.perform_later(shop_domain: session.shop)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ShopifyApp
|
4
|
+
module Auth
|
5
|
+
class TokenExchange
|
6
|
+
attr_reader :id_token
|
7
|
+
|
8
|
+
def self.perform(id_token)
|
9
|
+
new(id_token).perform
|
10
|
+
end
|
11
|
+
|
12
|
+
def initialize(id_token)
|
13
|
+
@id_token = id_token
|
14
|
+
end
|
15
|
+
|
16
|
+
def perform
|
17
|
+
domain = ShopifyAPI::Auth::JwtPayload.new(id_token).shopify_domain
|
18
|
+
|
19
|
+
Logger.info("Performing Token Exchange for [#{domain}] - (Offline)")
|
20
|
+
session = exchange_token(
|
21
|
+
shop: domain,
|
22
|
+
id_token: id_token,
|
23
|
+
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
|
24
|
+
)
|
25
|
+
|
26
|
+
if online_token_configured?
|
27
|
+
Logger.info("Performing Token Exchange for [#{domain}] - (Online)")
|
28
|
+
session = exchange_token(
|
29
|
+
shop: domain,
|
30
|
+
id_token: id_token,
|
31
|
+
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::ONLINE_ACCESS_TOKEN,
|
32
|
+
)
|
33
|
+
end
|
34
|
+
|
35
|
+
ShopifyApp.configuration.post_authenticate_tasks.perform(session)
|
36
|
+
|
37
|
+
session
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def exchange_token(shop:, id_token:, requested_token_type:)
|
43
|
+
session = ShopifyAPI::Auth::TokenExchange.exchange_token(
|
44
|
+
shop: shop,
|
45
|
+
session_token: id_token,
|
46
|
+
requested_token_type: requested_token_type,
|
47
|
+
)
|
48
|
+
|
49
|
+
SessionRepository.store_session(session)
|
50
|
+
|
51
|
+
session
|
52
|
+
rescue ShopifyAPI::Errors::InvalidJwtTokenError
|
53
|
+
Logger.error("Invalid id token '#{id_token}' during token exchange")
|
54
|
+
raise
|
55
|
+
rescue ShopifyAPI::Errors::HttpResponseError => error
|
56
|
+
Logger.error(
|
57
|
+
"A #{error.code} error (#{error.class}) occurred during the token exchange. Response: #{error.response.body}",
|
58
|
+
)
|
59
|
+
raise
|
60
|
+
rescue ActiveRecord::RecordNotUnique
|
61
|
+
Logger.debug("Session not stored due to concurrent token exchange calls")
|
62
|
+
session
|
63
|
+
rescue => error
|
64
|
+
Logger.error("An error occurred during the token exchange: [#{error.class}] #{error.message}")
|
65
|
+
raise
|
66
|
+
end
|
67
|
+
|
68
|
+
def online_token_configured?
|
69
|
+
ShopifyApp.configuration.online_token_configured?
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -29,6 +29,9 @@ module ShopifyApp
|
|
29
29
|
attr_writer :login_callback_url
|
30
30
|
attr_accessor :embedded_redirect_url
|
31
31
|
|
32
|
+
# customize post authenticate tasks
|
33
|
+
attr_accessor :custom_post_authenticate_tasks
|
34
|
+
|
32
35
|
# customise ActiveJob queue names
|
33
36
|
attr_accessor :scripttags_manager_queue_name
|
34
37
|
attr_accessor :webhooks_manager_queue_name
|
@@ -45,8 +48,8 @@ module ShopifyApp
|
|
45
48
|
# takes a ShopifyApp::BillingConfiguration object
|
46
49
|
attr_accessor :billing
|
47
50
|
|
48
|
-
#
|
49
|
-
attr_accessor :
|
51
|
+
# Enables new authorization flow using token exchange
|
52
|
+
attr_accessor :new_embedded_auth_strategy
|
50
53
|
|
51
54
|
def initialize
|
52
55
|
@root_url = "/"
|
@@ -54,6 +57,8 @@ module ShopifyApp
|
|
54
57
|
@scripttags_manager_queue_name = Rails.application.config.active_job.queue_name
|
55
58
|
@webhooks_manager_queue_name = Rails.application.config.active_job.queue_name
|
56
59
|
@disable_webpacker = ENV["SHOPIFY_APP_DISABLE_WEBPACKER"].present?
|
60
|
+
|
61
|
+
log_callback_controller_method_deprecation
|
57
62
|
end
|
58
63
|
|
59
64
|
def login_url
|
@@ -124,7 +129,53 @@ module ShopifyApp
|
|
124
129
|
end
|
125
130
|
|
126
131
|
def use_new_embedded_auth_strategy?
|
127
|
-
|
132
|
+
new_embedded_auth_strategy && embedded_app?
|
133
|
+
end
|
134
|
+
|
135
|
+
def online_token_configured?
|
136
|
+
!ShopifyApp.configuration.user_session_repository.blank? && ShopifyApp::SessionRepository.user_storage.present?
|
137
|
+
end
|
138
|
+
|
139
|
+
def post_authenticate_tasks
|
140
|
+
@post_authenticate_tasks || begin
|
141
|
+
if custom_post_authenticate_tasks
|
142
|
+
custom_class = if custom_post_authenticate_tasks.respond_to?(:safe_constantize)
|
143
|
+
custom_post_authenticate_tasks.safe_constantize
|
144
|
+
else
|
145
|
+
custom_post_authenticate_tasks
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
task_class = custom_class || ShopifyApp::Auth::PostAuthenticateTasks
|
150
|
+
|
151
|
+
[
|
152
|
+
:perform,
|
153
|
+
].each do |method|
|
154
|
+
raise(
|
155
|
+
::ShopifyApp::ConfigurationError,
|
156
|
+
"Missing method - '#{method}' for custom_post_authenticate_tasks",
|
157
|
+
) unless task_class.respond_to?(method)
|
158
|
+
end
|
159
|
+
|
160
|
+
task_class
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
private
|
165
|
+
|
166
|
+
def log_callback_controller_method_deprecation
|
167
|
+
return unless Rails.env.development?
|
168
|
+
|
169
|
+
# TODO: Remove this before releasing v23.0.0
|
170
|
+
message = <<~EOS
|
171
|
+
================================================
|
172
|
+
=> Upcoming deprecation in v23.0:
|
173
|
+
* 'CallbackController::perform_after_authenticate_job' and related methods 'install_webhooks', 'perform_after_authenticate_job'
|
174
|
+
* will be deprecated from CallbackController in the next major release. If you need to customize
|
175
|
+
* post authentication tasks, see https://github.com/Shopify/shopify_app/blob/main/docs/shopify_app/authentication.md#post-authenticate-tasks
|
176
|
+
================================================
|
177
|
+
EOS
|
178
|
+
puts message
|
128
179
|
end
|
129
180
|
end
|
130
181
|
|
@@ -5,6 +5,7 @@ module ShopifyApp
|
|
5
5
|
extend ActiveSupport::Concern
|
6
6
|
|
7
7
|
include ShopifyApp::FrameAncestors
|
8
|
+
include ShopifyApp::SanitizedParams
|
8
9
|
|
9
10
|
included do
|
10
11
|
layout :embedded_app_layout
|
@@ -13,6 +14,22 @@ module ShopifyApp
|
|
13
14
|
|
14
15
|
protected
|
15
16
|
|
17
|
+
def redirect_to_embed_app_in_admin
|
18
|
+
ShopifyApp::Logger.debug("Redirecting to embed app in admin")
|
19
|
+
|
20
|
+
host = if params[:host]
|
21
|
+
params[:host]
|
22
|
+
elsif params[:shop]
|
23
|
+
Base64.encode64("#{sanitized_shop_name}/admin")
|
24
|
+
else
|
25
|
+
return redirect_to(ShopifyApp.configuration.login_url)
|
26
|
+
end
|
27
|
+
|
28
|
+
redirect_path = ShopifyAPI::Auth.embedded_app_url(host)
|
29
|
+
redirect_path = ShopifyApp.configuration.root_url if deduced_phishing_attack?(redirect_path)
|
30
|
+
redirect_to(redirect_path, allow_other_host: true)
|
31
|
+
end
|
32
|
+
|
16
33
|
def use_embedded_app_layout?
|
17
34
|
ShopifyApp.configuration.embedded_app?
|
18
35
|
end
|
@@ -27,5 +44,15 @@ module ShopifyApp
|
|
27
44
|
response.set_header("P3P", 'CP="Not used"')
|
28
45
|
response.headers.except!("X-Frame-Options")
|
29
46
|
end
|
47
|
+
|
48
|
+
def deduced_phishing_attack?(decoded_host)
|
49
|
+
sanitized_host = ShopifyApp::Utils.sanitize_shop_domain(decoded_host)
|
50
|
+
if sanitized_host.nil?
|
51
|
+
message = "Host param for redirect to embed app in admin is not from a trusted domain, " \
|
52
|
+
"redirecting to root as this is likely a phishing attack."
|
53
|
+
ShopifyApp::Logger.info(message)
|
54
|
+
end
|
55
|
+
sanitized_host.nil?
|
56
|
+
end
|
30
57
|
end
|
31
58
|
end
|
@@ -27,7 +27,7 @@ module ShopifyApp
|
|
27
27
|
|
28
28
|
unless has_payment
|
29
29
|
if request.xhr?
|
30
|
-
|
30
|
+
RedirectForEmbedded.add_app_bridge_redirect_url_header(confirmation_url, response)
|
31
31
|
ShopifyApp::Logger.debug("Responding with 401 unauthorized")
|
32
32
|
head(:unauthorized)
|
33
33
|
elsif ShopifyApp.configuration.embedded_app?
|
@@ -45,8 +45,16 @@ module ShopifyApp
|
|
45
45
|
end
|
46
46
|
|
47
47
|
def handle_billing_error(error)
|
48
|
-
|
49
|
-
|
48
|
+
ShopifyApp::Logger.warn("Encountered billing error - #{error.message}: #{error.errors}\n" \
|
49
|
+
"Redirecting to login page")
|
50
|
+
|
51
|
+
login_url = ShopifyApp.configuration.login_url
|
52
|
+
if request.xhr?
|
53
|
+
RedirectForEmbedded.add_app_bridge_redirect_url_header(login_url, response)
|
54
|
+
head(:unauthorized)
|
55
|
+
else
|
56
|
+
fullpage_redirect_to(login_url)
|
57
|
+
end
|
50
58
|
end
|
51
59
|
|
52
60
|
def has_active_payment?(session)
|
@@ -16,6 +16,7 @@ module ShopifyApp
|
|
16
16
|
end
|
17
17
|
|
18
18
|
rescue_from ShopifyAPI::Errors::HttpResponseError, with: :handle_http_error
|
19
|
+
include ShopifyApp::WithShopifyIdToken
|
19
20
|
end
|
20
21
|
|
21
22
|
ACCESS_TOKEN_REQUIRED_HEADER = "X-Shopify-API-Request-Failure-Unauthorized"
|
@@ -53,7 +54,7 @@ module ShopifyApp
|
|
53
54
|
@current_shopify_session ||= begin
|
54
55
|
cookie_name = ShopifyAPI::Auth::Oauth::SessionCookie::SESSION_COOKIE_NAME
|
55
56
|
load_current_session(
|
56
|
-
|
57
|
+
shopify_id_token: shopify_id_token,
|
57
58
|
cookies: { cookie_name => cookies.encrypted[cookie_name] },
|
58
59
|
is_online: online_token_configured?,
|
59
60
|
)
|
@@ -78,13 +79,6 @@ module ShopifyApp
|
|
78
79
|
response.set_header(ACCESS_TOKEN_REQUIRED_HEADER, "true")
|
79
80
|
end
|
80
81
|
|
81
|
-
def jwt_expire_at
|
82
|
-
expire_at = request.env["jwt.expire_at"]
|
83
|
-
return unless expire_at
|
84
|
-
|
85
|
-
expire_at - 5.seconds # 5s gap to start fetching new token in advance
|
86
|
-
end
|
87
|
-
|
88
82
|
def add_top_level_redirection_headers(url: nil, ignore_response_code: false)
|
89
83
|
if request.xhr? && (ignore_response_code || response.code.to_i == 401)
|
90
84
|
ShopifyApp::Logger.debug("Adding top level redirection headers")
|
@@ -94,8 +88,8 @@ module ShopifyApp
|
|
94
88
|
params[:shop] = if current_shopify_session
|
95
89
|
current_shopify_session.shop
|
96
90
|
|
97
|
-
elsif
|
98
|
-
jwt_payload = ShopifyAPI::Auth::JwtPayload.new(
|
91
|
+
elsif shopify_id_token
|
92
|
+
jwt_payload = ShopifyAPI::Auth::JwtPayload.new(shopify_id_token)
|
99
93
|
jwt_payload.shop
|
100
94
|
end
|
101
95
|
end
|
@@ -103,21 +97,12 @@ module ShopifyApp
|
|
103
97
|
url ||= login_url_with_optional_shop
|
104
98
|
|
105
99
|
ShopifyApp::Logger.debug("Setting Reauthorize-Url to #{url}")
|
106
|
-
|
107
|
-
response.set_header("X-Shopify-API-Request-Failure-Reauthorize-Url", url)
|
100
|
+
RedirectForEmbedded.add_app_bridge_redirect_url_header(url, response)
|
108
101
|
end
|
109
102
|
end
|
110
103
|
|
111
104
|
protected
|
112
105
|
|
113
|
-
def jwt_shopify_domain
|
114
|
-
request.env["jwt.shopify_domain"]
|
115
|
-
end
|
116
|
-
|
117
|
-
def jwt_shopify_user_id
|
118
|
-
request.env["jwt.shopify_user_id"]
|
119
|
-
end
|
120
|
-
|
121
106
|
def host
|
122
107
|
params[:host]
|
123
108
|
end
|
@@ -263,7 +248,7 @@ module ShopifyApp
|
|
263
248
|
end
|
264
249
|
|
265
250
|
def online_token_configured?
|
266
|
-
|
251
|
+
ShopifyApp.configuration.online_token_configured?
|
267
252
|
end
|
268
253
|
|
269
254
|
def user_session_expected?
|
@@ -273,10 +258,10 @@ module ShopifyApp
|
|
273
258
|
online_token_configured?
|
274
259
|
end
|
275
260
|
|
276
|
-
def load_current_session(
|
261
|
+
def load_current_session(shopify_id_token: nil, cookies: nil, is_online: false)
|
277
262
|
return ShopifyAPI::Context.load_private_session if ShopifyAPI::Context.private?
|
278
263
|
|
279
|
-
session_id = ShopifyAPI::Utils::SessionUtils.current_session_id(
|
264
|
+
session_id = ShopifyAPI::Utils::SessionUtils.current_session_id(shopify_id_token, cookies, is_online)
|
280
265
|
return nil unless session_id
|
281
266
|
|
282
267
|
ShopifyApp::SessionRepository.load_session(session_id)
|
@@ -4,6 +4,11 @@ module ShopifyApp
|
|
4
4
|
module RedirectForEmbedded
|
5
5
|
include ShopifyApp::SanitizedParams
|
6
6
|
|
7
|
+
def self.add_app_bridge_redirect_url_header(url, response)
|
8
|
+
response.set_header("X-Shopify-API-Request-Failure-Reauthorize", "1")
|
9
|
+
response.set_header("X-Shopify-API-Request-Failure-Reauthorize-Url", url)
|
10
|
+
end
|
11
|
+
|
7
12
|
private
|
8
13
|
|
9
14
|
def embedded_redirect_url?
|