Skip to content

Commit 33e5cf8

Browse files
authored
Merge pull request #1362 from Shopify/fa/develop-app-access-622
[Feature]: Add support for client credentials grant
2 parents f21e275 + 89869ec commit 33e5cf8

File tree

6 files changed

+176
-6
lines changed

6 files changed

+176
-6
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
Note: For changes to the API, see https://shopify.dev/changelog?filter=api
44
## Unreleased
55

6+
- [#1362](https://github.com/Shopify/shopify-api-ruby/pull/1362) Add support for client credentials grant
7+
68
## 14.8.0
79

810
- [#1355](https://github.com/Shopify/shopify-api-ruby/pull/1355) Add support for 2025-01 API version

docs/usage/oauth.md

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ For more information on authenticating a Shopify app please see the [Types of Au
1111
- [Note about Rails](#note-about-rails)
1212
- [Performing OAuth](#performing-oauth-1)
1313
- [Token Exchange](#token-exchange)
14-
- [Authorization Code Grant Flow](#authorization-code-grant-flow)
14+
- [Authorization Code Grant](#authorization-code-grant)
15+
- [Client Credentials Grant](#client-credentials-grant)
1516
- [Using OAuth Session to make authenticated API calls](#using-oauth-session-to-make-authenticated-api-calls)
1617

1718
## Session Persistence
@@ -31,10 +32,14 @@ with [token exchange](#token-exchange) instead of the authorization code grant f
3132
- Recommended and is only available for embedded apps
3233
- Doesn't require redirects, which makes authorization faster and prevents flickering when loading the app
3334
- Access scope changes are handled by [Shopify managed installation](https://shopify.dev/docs/apps/auth/installation#shopify-managed-installation)
34-
2. [Authorization Code Grant Flow](#authorization-code-grant-flow)
35+
2. [Authorization Code Grant](#authorization-code-grant)
3536
- OAuth flow that requires the app to redirect the user to Shopify for installation/authorization of the app to access the shop's data.
3637
- Suitable for non-embedded apps
3738
- Installations, and access scope changes are managed by the app
39+
3. [Client Credentials Grant](#client-credentials-grant)
40+
- Suitable for apps without a UI
41+
- Doesn't require user interaction in the browser
42+
- Access scope changes are handled by [Shopify managed installation](https://shopify.dev/docs/apps/auth/installation#shopify-managed-installation)
3843

3944
## Note about Rails
4045
If using in the Rails framework, we highly recommend you use the [shopify_app](https://github.com/Shopify/shopify_app) gem to perform OAuth, you won't have to follow the instructions below to start your own OAuth flow.
@@ -94,7 +99,7 @@ end
9499

95100
```
96101

97-
### Authorization Code Grant Flow
102+
### Authorization Code Grant
98103
##### Steps
99104
1. [Add a route to start OAuth](#1-add-a-route-to-start-oauth)
100105
2. [Add an Oauth callback route](#2-add-an-oauth-callback-route)
@@ -265,9 +270,41 @@ def callback
265270
end
266271
end
267272
```
268-
269273
⚠️ You can see a concrete example in the `ShopifyApp` gem's [CallbackController](https://github.com/Shopify/shopify_app/blob/main/app/controllers/shopify_app/callback_controller.rb).
270274

275+
### Client Credentials Grant
276+
277+
> [!NOTE]
278+
> You can only use the client credentials grant when building apps for your own organization.
279+
280+
> [!WARNING]
281+
> [token exchange](#token-exchange) (for embedded apps) or the [authorization code grant](#authorization-code-grant) should be used instead of the client credentials grant, if your app is a browser based web app.
282+
283+
#### Perform Client Credentials Grant
284+
Use [`ShopifyAPI::Auth::ClientCredentials`](https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/auth/client_credentials.rb) to
285+
exchange the [app's client ID and client secret](https://shopify.dev/docs/apps/build/authentication-authorization/client-secrets) for an access token.
286+
#### Input
287+
| Parameter | Type | Required? | Default Value | Notes |
288+
| -------------- | ---------------------- | :-------: | :-----------: | ----------------------------------------------------------------------------------------------------------- |
289+
| `shop` | `String` | Yes | - | A Shopify domain name in the form `{exampleshop}.myshopify.com`. |
290+
291+
#### Output
292+
This method returns the new `ShopifyAPI::Auth::Session` object from the client credentials grant, your app should store this `Session` object to be used later [when making authenticated API calls](#using-oauth-session-to-make-authenticated-api-calls).
293+
294+
#### Example
295+
```ruby
296+
297+
# `shop` is the shop domain name - "this-is-my-example-shop.myshopify.com"
298+
299+
def authenticate(shop)
300+
session = ShopifyAPI::Auth::ClientCredentials.client_credentials(
301+
shop: shop,
302+
)
303+
SessionRepository.store_session(session)
304+
end
305+
306+
```
307+
271308
## Using OAuth Session to make authenticated API calls
272309
Once your OAuth flow is complete, and you have persisted your `Session` object, you may use that `Session` object to make authenticated API calls.
273310

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module ShopifyAPI
5+
module Auth
6+
module ClientCredentials
7+
extend T::Sig
8+
9+
CLIENT_CREDENTIALS_GRANT_TYPE = "client_credentials"
10+
11+
class << self
12+
extend T::Sig
13+
14+
sig do
15+
params(
16+
shop: String,
17+
).returns(ShopifyAPI::Auth::Session)
18+
end
19+
def client_credentials(shop:)
20+
unless ShopifyAPI::Context.setup?
21+
raise ShopifyAPI::Errors::ContextNotSetupError,
22+
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
23+
end
24+
25+
shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
26+
body = {
27+
client_id: ShopifyAPI::Context.api_key,
28+
client_secret: ShopifyAPI::Context.api_secret_key,
29+
grant_type: CLIENT_CREDENTIALS_GRANT_TYPE,
30+
}
31+
32+
client = Clients::HttpClient.new(session: shop_session, base_path: "/admin/oauth")
33+
response =
34+
client.request(
35+
Clients::HttpRequest.new(
36+
http_method: :post,
37+
path: "access_token",
38+
body: body,
39+
body_type: "application/json",
40+
),
41+
)
42+
response_hash = T.cast(response.body, T::Hash[String, T.untyped]).to_h
43+
44+
Session.from(
45+
shop: shop,
46+
access_token_response: Oauth::AccessTokenResponse.from_hash(response_hash),
47+
)
48+
end
49+
end
50+
end
51+
end
52+
end

lib/shopify_api/auth/session.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,16 @@ def from(shop:, access_token_response:)
9595

9696
if is_online
9797
associated_user = T.must(access_token_response.associated_user)
98-
expires = Time.now + access_token_response.expires_in.to_i
9998
associated_user_scope = access_token_response.associated_user_scope
10099
id = "#{shop}_#{associated_user.id}"
101100
else
102101
id = "offline_#{shop}"
103102
end
104103

104+
if access_token_response.expires_in
105+
expires = Time.now + access_token_response.expires_in.to_i
106+
end
107+
105108
new(
106109
id: id,
107110
shop: shop,
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# typed: false
2+
# frozen_string_literal: true
3+
4+
require_relative "../test_helper"
5+
6+
module ShopifyAPITest
7+
module Auth
8+
class ClientCredentialsTest < Test::Unit::TestCase
9+
def setup
10+
super()
11+
12+
@shop = "test-shop.myshopify.com"
13+
@client_credentials_request = {
14+
client_id: ShopifyAPI::Context.api_key,
15+
client_secret: ShopifyAPI::Context.api_secret_key,
16+
grant_type: "client_credentials",
17+
}
18+
@offline_token_response = {
19+
access_token: SecureRandom.alphanumeric(10),
20+
scope: "scope1,scope2",
21+
expires_in: 1000,
22+
}
23+
end
24+
25+
def test_client_credentials_context_not_setup
26+
modify_context(api_key: "", api_secret_key: "", host: "")
27+
28+
assert_raises(ShopifyAPI::Errors::ContextNotSetupError) do
29+
ShopifyAPI::Auth::ClientCredentials.client_credentials(shop: @shop)
30+
end
31+
end
32+
33+
def test_client_credentials_offline_token
34+
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
35+
.with(body: @client_credentials_request)
36+
.to_return(body: @offline_token_response.to_json, headers: { content_type: "application/json" })
37+
expected_session = ShopifyAPI::Auth::Session.new(
38+
id: "offline_#{@shop}",
39+
shop: @shop,
40+
access_token: @offline_token_response[:access_token],
41+
scope: @offline_token_response[:scope],
42+
is_online: false,
43+
expires: Time.now + @offline_token_response[:expires_in].to_i,
44+
)
45+
46+
session = ShopifyAPI::Auth::ClientCredentials.client_credentials(shop: @shop)
47+
48+
assert_equal(expected_session, session)
49+
end
50+
end
51+
end
52+
end

test/auth/session_test.rb

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def test_temp_with_block_var
7979
assert_equal(session, ShopifyAPI::Context.active_session)
8080
end
8181

82-
def test_from_with_offline_access_token_response
82+
def test_from_with_offline_access_token_response_with_no_expires_in
8383
shop = "test-shop"
8484
response = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new(
8585
access_token: "token",
@@ -103,6 +103,30 @@ def test_from_with_offline_access_token_response
103103
assert_equal(expected_session, session)
104104
end
105105

106+
def test_from_with_offline_access_token_response_with_expires_in
107+
shop = "test-shop"
108+
response = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new(
109+
access_token: "token",
110+
scope: "scope1, scope2",
111+
expires_in: 1000,
112+
)
113+
114+
expected_session = ShopifyAPI::Auth::Session.new(
115+
id: "offline_#{shop}",
116+
shop: shop,
117+
access_token: response.access_token,
118+
scope: response.scope,
119+
is_online: false,
120+
associated_user_scope: nil,
121+
associated_user: nil,
122+
expires: Time.now + response.expires_in,
123+
shopify_session_id: response.session,
124+
)
125+
126+
session = ShopifyAPI::Auth::Session.from(shop: shop, access_token_response: response)
127+
assert_equal(expected_session, session)
128+
end
129+
106130
def test_from_with_online_access_token_response
107131
shop = "test-shop"
108132
associated_user = ShopifyAPI::Auth::AssociatedUser.new(

0 commit comments

Comments
 (0)