Skip to content

Commit

Permalink
Add support for client credentials in Shopify API Ruby
Browse files Browse the repository at this point in the history
  • Loading branch information
fwaadahmad1 committed Feb 18, 2025
1 parent f21e275 commit 59bb68c
Show file tree
Hide file tree
Showing 6 changed files with 176 additions and 3 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Note: For changes to the API, see https://shopify.dev/changelog?filter=api
## Unreleased

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

## 14.8.0

- [#1355](https://github.com/Shopify/shopify-api-ruby/pull/1355) Add support for 2025-01 API version
Expand Down
41 changes: 40 additions & 1 deletion docs/usage/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ For more information on authenticating a Shopify app please see the [Types of Au
- [Performing OAuth](#performing-oauth-1)
- [Token Exchange](#token-exchange)
- [Authorization Code Grant Flow](#authorization-code-grant-flow)
- [Client Credentials Grant](#client-credentials-grant)
- [Using OAuth Session to make authenticated API calls](#using-oauth-session-to-make-authenticated-api-calls)

## Session Persistence
Expand All @@ -35,6 +36,10 @@ with [token exchange](#token-exchange) instead of the authorization code grant f
- OAuth flow that requires the app to redirect the user to Shopify for installation/authorization of the app to access the shop's data.
- Suitable for non-embedded apps
- Installations, and access scope changes are managed by the app
3. [Client Credentials Grant](#client-credentials-grant)
- Suitable for backend apps without UI
- Doesn't require user interaction in the browser
- Access scopes can be configured either in the Developer Dashboard when creating an app version or in your app's [TOML configuration file](https://shopify.dev/docs/apps/build/cli-for-apps/app-configuration#access_scopes)

## Note about Rails
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.
Expand All @@ -49,6 +54,8 @@ If you aren't using Rails, you can look at how the `ShopifyApp` gem handles OAut
- Triggering and redirecting user to **begin** OAuth flow
- [Callback Controller](https://github.com/Shopify/shopify_app/blob/main/app/controllers/shopify_app/callback_controller.rb)
- Creating / storing sessions to **complete** the OAuth flow
- [Client Credentials](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/auth/client_credentials.rb)
- Completes client credentials flow to get offline access tokens with expiration time.

## Performing OAuth
### Token Exchange
Expand Down Expand Up @@ -265,9 +272,41 @@ def callback
end
end
```

⚠️ 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).

### Client Credentials Grant

> [!NOTE]
> You should consider using the client credentials grant only when building apps for your own organization.
> [!WARNING]
> [token exchange](#token-exchange) (for embedded apps) or the [authorization code grant flow](#authorization-code-grant-flow) should be used instead of the client credentials grant, if your app is a browser based web app.
#### Perform Client Credentials Grant
Use [`ShopifyAPI::Auth::ClientCredentials`](https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/auth/client_credentials.rb) to
exchange the [app's client ID and client secret](https://shopify.dev/docs/apps/build/authentication-authorization/client-secrets) for an access token.
#### Input
| Parameter | Type | Required? | Default Value | Notes |
| -------------- | ---------------------- | :-------: | :-----------: | ----------------------------------------------------------------------------------------------------------- |
| `shop` | `String` | Yes | - | A Shopify domain name in the form `{exampleshop}.myshopify.com`. |

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

#### Example
```ruby

# `shop` is the shop domain name - "this-is-my-example-shop.myshopify.com"

def authenticate(shop)
session = ShopifyAPI::Auth::ClientCredentials.client_credentials(
shop: shop,
)
SessionRepository.store_session(session)
end

```

## Using OAuth Session to make authenticated API calls
Once your OAuth flow is complete, and you have persisted your `Session` object, you may use that `Session` object to make authenticated API calls.

Expand Down
52 changes: 52 additions & 0 deletions lib/shopify_api/auth/client_credentials.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# typed: strict
# frozen_string_literal: true

module ShopifyAPI
module Auth
module ClientCredentials
extend T::Sig

CLIENT_CREDENTIALS_GRANT_TYPE = "client_credentials"

class << self
extend T::Sig

sig do
params(
shop: String,
).returns(ShopifyAPI::Auth::Session)
end
def client_credentials(shop:)
unless ShopifyAPI::Context.setup?
raise ShopifyAPI::Errors::ContextNotSetupError,
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
end

shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
body = {
client_id: ShopifyAPI::Context.api_key,
client_secret: ShopifyAPI::Context.api_secret_key,
grant_type: CLIENT_CREDENTIALS_GRANT_TYPE,
}

client = Clients::HttpClient.new(session: shop_session, base_path: "/admin/oauth")
response =
client.request(
Clients::HttpRequest.new(
http_method: :post,
path: "access_token",
body: body,
body_type: "application/json",
),
)
response_hash = T.cast(response.body, T::Hash[String, T.untyped]).to_h

Session.from(
shop: shop,
access_token_response: Oauth::AccessTokenResponse.from_hash(response_hash),
)
end
end
end
end
end
5 changes: 4 additions & 1 deletion lib/shopify_api/auth/session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,16 @@ def from(shop:, access_token_response:)

if is_online
associated_user = T.must(access_token_response.associated_user)
expires = Time.now + access_token_response.expires_in.to_i
associated_user_scope = access_token_response.associated_user_scope
id = "#{shop}_#{associated_user.id}"
else
id = "offline_#{shop}"
end

if access_token_response.expires_in
expires = Time.now + access_token_response.expires_in.to_i
end

new(
id: id,
shop: shop,
Expand Down
53 changes: 53 additions & 0 deletions test/auth/client_credentials_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# typed: false
# frozen_string_literal: true

require_relative "../test_helper"

module ShopifyAPITest
module Auth
class ClientCredentialsTest < Test::Unit::TestCase
def setup
super()

@stubbed_time_now = Time.now
@shop = "test-shop.myshopify.com"
@client_credentials_request = {
client_id: ShopifyAPI::Context.api_key,
client_secret: ShopifyAPI::Context.api_secret_key,
grant_type: "client_credentials",
}
@offline_token_response = {
access_token: SecureRandom.alphanumeric(10),
scope: "scope1,scope2",
expires_in: 1000,
}
end

def test_client_credentials_context_not_setup
modify_context(api_key: "", api_secret_key: "", host: "")

assert_raises(ShopifyAPI::Errors::ContextNotSetupError) do
ShopifyAPI::Auth::ClientCredentials.client_credentials(shop: @shop)
end
end

def test_client_credentials_offline_token
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
.with(body: @client_credentials_request)
.to_return(body: @offline_token_response.to_json, headers: { content_type: "application/json" })
expected_session = ShopifyAPI::Auth::Session.new(
id: "offline_#{@shop}",
shop: @shop,
access_token: @offline_token_response[:access_token],
scope: @offline_token_response[:scope],
is_online: false,
expires: @stubbed_time_now + @offline_token_response[:expires_in].to_i,
)

session = ShopifyAPI::Auth::ClientCredentials.client_credentials(shop: @shop)

assert_equal(expected_session, session)
end
end
end
end
26 changes: 25 additions & 1 deletion test/auth/session_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def test_temp_with_block_var
assert_equal(session, ShopifyAPI::Context.active_session)
end

def test_from_with_offline_access_token_response
def test_from_with_offline_access_token_response_with_no_expires_in
shop = "test-shop"
response = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new(
access_token: "token",
Expand All @@ -103,6 +103,30 @@ def test_from_with_offline_access_token_response
assert_equal(expected_session, session)
end

def test_from_with_offline_access_token_response_with_expires_in
shop = "test-shop"
response = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new(
access_token: "token",
scope: "scope1, scope2",
expires_in: 1000,
)

expected_session = ShopifyAPI::Auth::Session.new(
id: "offline_#{shop}",
shop: shop,
access_token: response.access_token,
scope: response.scope,
is_online: false,
associated_user_scope: nil,
associated_user: nil,
expires: Time.now + response.expires_in,
shopify_session_id: response.session,
)

session = ShopifyAPI::Auth::Session.from(shop: shop, access_token_response: response)
assert_equal(expected_session, session)
end

def test_from_with_online_access_token_response
shop = "test-shop"
associated_user = ShopifyAPI::Auth::AssociatedUser.new(
Expand Down

0 comments on commit 59bb68c

Please sign in to comment.