Skip to content

Commit d29b853

Browse files
committed
add mutex around offline user token upsert requests
This can cause bugs if more then one of these requests go out at once, only one per app is valid and if more then one simultaneous request happens only the last requested from shopify is valid. This moves the get_user_token from JWTSessionToken to UserToken
1 parent 506e0a3 commit d29b853

File tree

7 files changed

+118
-71
lines changed

7 files changed

+118
-71
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
## Unreleased
22

3+
- Refactor: adds a mutex around the offline shop token and online user token upsert calls.
4+
35
## 0.16.4
46

57
- Fix: Add support for larger webhook payload bodies (15MB vs. the previous 8MB)

lib/shopify_api/application.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ defmodule ShopifyAPI.Application do
1010
RateLimiting.GraphQLTracker.init()
1111

1212
# Define workers and child supervisors to be supervised
13-
children = []
13+
children = [
14+
{Mutex, name: ShopifyAPI.OfflineToken},
15+
{Mutex, name: ShopifyAPI.OfflineUserToken}
16+
]
1417

1518
# See https://hexdocs.pm/elixir/Supervisor.html
1619
# for other strategies and supported options

lib/shopify_api/auth_token.ex

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
defmodule ShopifyAPI.AuthToken do
2+
require Logger
3+
24
@derive {Jason.Encoder, only: [:code, :app_name, :shop_name, :token, :timestamp, :plus]}
35
defstruct code: "",
46
app_name: "",
@@ -32,7 +34,7 @@ defmodule ShopifyAPI.AuthToken do
3234
def create_key(shop, app), do: "#{shop}:#{app}"
3335

3436
@spec new(App.t(), String.t(), String.t(), String.t()) :: t()
35-
def new(app, myshopify_domain, auth_code, token) do
37+
def new(%App{} = app, myshopify_domain, auth_code, token) do
3638
%__MODULE__{
3739
app_name: app.name,
3840
shop_name: myshopify_domain,
@@ -42,7 +44,39 @@ defmodule ShopifyAPI.AuthToken do
4244
end
4345

4446
@spec from_auth_request(App.t(), String.t(), String.t(), map()) :: t()
45-
def from_auth_request(app, myshopify_domain, code \\ "", attrs) when is_struct(app, App) do
46-
new(app, myshopify_domain, code, attrs["access_token"])
47+
def from_auth_request(%App{} = app, myshopify_domain, code \\ "", %{} = attrs),
48+
do: new(app, myshopify_domain, code, attrs["access_token"])
49+
50+
@spec get_offline_token(App.t(), String.t(), String.t()) ::
51+
ok_t() | {:error, :failed_fetching_online_token}
52+
def get_offline_token(%App{} = app, myshopify_domain, token) do
53+
case ShopifyAPI.AuthTokenServer.get(myshopify_domain, app.name) do
54+
{:ok, _} = resp -> resp
55+
_ -> mutexed_get_offline_token(app, myshopify_domain, token)
56+
end
57+
end
58+
59+
defp mutexed_get_offline_token(app, myshopify_domain, token) do
60+
mutex_key = {app.name, myshopify_domain}
61+
62+
Mutex.with_lock(ShopifyAPI.OfflineToken, mutex_key, fn ->
63+
# Try fetching a valid token from cache, a new one may have been put in here since the
64+
# first call
65+
case ShopifyAPI.AuthTokenServer.get(myshopify_domain, app.name) do
66+
{:ok, _} = resp -> resp
67+
_ -> request_offline_token(app, myshopify_domain, token)
68+
end
69+
end)
70+
end
71+
72+
defp request_offline_token(app, myshopify_domain, token) do
73+
case ShopifyAPI.AuthRequest.request_offline_access_token(app, myshopify_domain, token) do
74+
{:ok, token} ->
75+
Task.async(fn -> ShopifyAPI.Shop.post_login(token) end)
76+
{:ok, token}
77+
78+
error ->
79+
error
80+
end
4781
end
4882
end

lib/shopify_api/jwt_session_token.ex

Lines changed: 0 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -42,68 +42,4 @@ defmodule ShopifyAPI.JWTSessionToken do
4242

4343
def user_id(_),
4444
do: {:error, "Invalid user token or no id"}
45-
46-
@spec get_offline_token(JOSE.JWT.t(), String.t()) ::
47-
{:ok, ShopifyAPI.AuthToken.t()}
48-
| {:error, :invalid_session_token}
49-
| {:error, :failed_fetching_online_token}
50-
def get_offline_token(%JOSE.JWT{} = jwt, token) do
51-
with {:ok, myshopify_domain} <- myshopify_domain(jwt),
52-
{:ok, app} <- app(jwt) do
53-
case ShopifyAPI.AuthTokenServer.get(myshopify_domain, app.name) do
54-
{:ok, _} = resp ->
55-
resp
56-
57-
{:error, _} ->
58-
Logger.warning("No token found, exchanging for new")
59-
60-
case ShopifyAPI.AuthRequest.request_offline_access_token(app, myshopify_domain, token) do
61-
{:ok, token} ->
62-
fire_post_login_hook(token)
63-
{:ok, token}
64-
65-
error ->
66-
error
67-
end
68-
end
69-
else
70-
error ->
71-
Logger.warning("failed getting required informatio from the JWT #{inspect(error)}")
72-
{:error, :invalid_session_token}
73-
end
74-
end
75-
76-
@spec get_user_token(JOSE.JWT.t(), String.t()) ::
77-
{:ok, ShopifyAPI.UserToken.t()}
78-
| {:error, :invalid_session_token}
79-
| {:error, :failed_fetching_online_token}
80-
def get_user_token(%JOSE.JWT{} = jwt, token) do
81-
with {:ok, myshopify_domain} <- myshopify_domain(jwt),
82-
{:ok, app} <- app(jwt),
83-
{:ok, user_id} <- user_id(jwt) do
84-
case ShopifyAPI.UserTokenServer.get_valid(myshopify_domain, app.name, user_id) do
85-
{:ok, _} = resp ->
86-
resp
87-
88-
{:error, :invalid_user_token} ->
89-
Logger.debug("Expired or no user token found, exchanging for new")
90-
91-
case ShopifyAPI.AuthRequest.request_online_access_token(app, myshopify_domain, token) do
92-
{:ok, user_token} ->
93-
fire_post_login_hook(user_token)
94-
{:ok, user_token}
95-
96-
error ->
97-
error
98-
end
99-
end
100-
else
101-
error ->
102-
Logger.warning("failed getting required informatio from the JWT #{inspect(error)}")
103-
{:error, :invalid_session_token}
104-
end
105-
end
106-
107-
defp fire_post_login_hook(user_token),
108-
do: Task.async(fn -> ShopifyAPI.Shop.post_login(user_token) end)
10945
end

lib/shopify_api/plugs/admin_authenticator.ex

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ defmodule ShopifyAPI.Plugs.AdminAuthenticator do
3434

3535
alias Plug.Conn
3636

37+
alias ShopifyAPI.AuthToken
3738
alias ShopifyAPI.JWTSessionToken
39+
alias ShopifyAPI.UserToken
3840

3941
@defaults [shopify_mount_path: "/shop"]
4042

@@ -56,8 +58,8 @@ defmodule ShopifyAPI.Plugs.AdminAuthenticator do
5658
:ok <- validate_hmac(app, conn.query_params),
5759
{:ok, myshopify_domain} <- JWTSessionToken.myshopify_domain(jwt),
5860
{:ok, shop} <- ShopifyAPI.ShopServer.get_or_create(myshopify_domain, true),
59-
{:ok, auth_token} <- JWTSessionToken.get_offline_token(jwt, token),
60-
{:ok, user_token} <- JWTSessionToken.get_user_token(jwt, token) do
61+
{:ok, auth_token} <- get_offline_token(jwt, token),
62+
{:ok, user_token} <- get_user_token(jwt, token) do
6163
conn
6264
|> assign_app(app)
6365
|> assign_shop(shop)
@@ -131,4 +133,27 @@ defmodule ShopifyAPI.Plugs.AdminAuthenticator do
131133
_ -> {:error, :invalid_hmac}
132134
end)
133135
end
136+
137+
defp get_user_token(jwt, token) do
138+
with {:ok, app} <- JWTSessionToken.app(jwt),
139+
{:ok, myshopify_domain} <- JWTSessionToken.myshopify_domain(jwt),
140+
{:ok, user_id} <- JWTSessionToken.user_id(jwt) do
141+
UserToken.get_user_token(app, myshopify_domain, user_id, token)
142+
else
143+
error ->
144+
Logger.warning("failed getting required information from the JWT #{inspect(error)}")
145+
{:error, :invalid_session_token}
146+
end
147+
end
148+
149+
defp get_offline_token(jwt, token) do
150+
with {:ok, app} <- JWTSessionToken.app(jwt),
151+
{:ok, myshopify_domain} <- JWTSessionToken.myshopify_domain(jwt) do
152+
AuthToken.get_offline_token(app, myshopify_domain, token)
153+
else
154+
error ->
155+
Logger.warning("failed getting required information from the JWT #{inspect(error)}")
156+
{:error, :invalid_session_token}
157+
end
158+
end
134159
end

lib/shopify_api/plugs/auth_shop_session_token.ex

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ defmodule ShopifyAPI.Plugs.AuthShopSessionToken do
2121
alias ShopifyAPI.AuthTokenServer
2222
alias ShopifyAPI.JWTSessionToken
2323
alias ShopifyAPI.ShopServer
24+
alias ShopifyAPI.UserToken
2425

2526
def init(opts), do: opts
2627

@@ -32,7 +33,7 @@ defmodule ShopifyAPI.Plugs.AuthShopSessionToken do
3233
{:ok, user_id} <- JWTSessionToken.user_id(jwt),
3334
{:ok, shop} <- ShopServer.get(myshopify_domain),
3435
{:ok, auth_token} <- AuthTokenServer.get(myshopify_domain, app.name),
35-
{:ok, user_token} <- JWTSessionToken.get_user_token(jwt, token) do
36+
{:ok, user_token} <- get_user_token(jwt, token) do
3637
conn
3738
|> assign(:app, app)
3839
|> assign(:shop, shop)
@@ -48,4 +49,16 @@ defmodule ShopifyAPI.Plugs.AuthShopSessionToken do
4849
|> halt()
4950
end
5051
end
52+
53+
defp get_user_token(jwt, token) do
54+
with {:ok, app} <- JWTSessionToken.app(jwt),
55+
{:ok, myshopify_domain} <- JWTSessionToken.myshopify_domain(jwt),
56+
{:ok, user_id} <- JWTSessionToken.user_id(jwt) do
57+
UserToken.get_user_token(app, myshopify_domain, user_id, token)
58+
else
59+
error ->
60+
Logger.warning("failed getting required information from the JWT #{inspect(error)}")
61+
{:error, :invalid_session_token}
62+
end
63+
end
5164
end

lib/shopify_api/user_token.ex

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ defmodule ShopifyAPI.UserToken do
33
Represents the auth token for individual users, Shopify documentation for the auth process
44
is here https://shopify.dev/docs/apps/auth/oauth/getting-started#online-access-mode
55
"""
6+
require Logger
67

78
@derive {Jason.Encoder,
89
only: [
@@ -79,4 +80,37 @@ defmodule ShopifyAPI.UserToken do
7980
scope: attrs["scope"]
8081
)
8182
end
83+
84+
@spec get_user_token(App.t(), String.t(), integer(), String.t()) ::
85+
{:ok, ShopifyAPI.UserToken.t()} | {:error, :failed_fetching_online_token}
86+
def get_user_token(app, myshopify_domain, user_id, token) do
87+
case ShopifyAPI.UserTokenServer.get_valid(myshopify_domain, app.name, user_id) do
88+
{:ok, _} = resp -> resp
89+
_ -> mutexed_get_user_token(app, myshopify_domain, user_id, token)
90+
end
91+
end
92+
93+
defp mutexed_get_user_token(app, myshopify_domain, user_id, token) do
94+
mutex_key = {app.name, user_id}
95+
96+
Mutex.with_lock(ShopifyAPI.OfflineUserToken, mutex_key, fn ->
97+
# Try fetching a valid token from cache, a new one may have been put in here since the
98+
# get_user_token/4 call
99+
case ShopifyAPI.UserTokenServer.get_valid(myshopify_domain, app.name, user_id) do
100+
{:ok, _} = resp -> resp
101+
_ -> request_offline_token(app, myshopify_domain, token)
102+
end
103+
end)
104+
end
105+
106+
defp request_offline_token(app, myshopify_domain, token) do
107+
case ShopifyAPI.AuthRequest.request_online_access_token(app, myshopify_domain, token) do
108+
{:ok, user_token} ->
109+
Task.async(fn -> ShopifyAPI.Shop.post_login(user_token) end)
110+
{:ok, user_token}
111+
112+
error ->
113+
error
114+
end
115+
end
82116
end

0 commit comments

Comments
 (0)