Skip to content

Commit

Permalink
Merge pull request #1362 from Shopify/fa/develop-app-access-622
Browse files Browse the repository at this point in the history
[Feature]: Add support for client credentials grant
  • Loading branch information
fwaadahmad1 authored Feb 21, 2025
2 parents f21e275 + 89869ec commit 33e5cf8
Show file tree
Hide file tree
Showing 6 changed files with 176 additions and 6 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
45 changes: 41 additions & 4 deletions docs/usage/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ For more information on authenticating a Shopify app please see the [Types of Au
- [Note about Rails](#note-about-rails)
- [Performing OAuth](#performing-oauth-1)
- [Token Exchange](#token-exchange)
- [Authorization Code Grant Flow](#authorization-code-grant-flow)
- [Authorization Code Grant](#authorization-code-grant)
- [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 @@ -31,10 +32,14 @@ with [token exchange](#token-exchange) instead of the authorization code grant f
- Recommended and is only available for embedded apps
- Doesn't require redirects, which makes authorization faster and prevents flickering when loading the app
- Access scope changes are handled by [Shopify managed installation](https://shopify.dev/docs/apps/auth/installation#shopify-managed-installation)
2. [Authorization Code Grant Flow](#authorization-code-grant-flow)
2. [Authorization Code Grant](#authorization-code-grant)
- 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 apps without a UI
- Doesn't require user interaction in the browser
- Access scope changes are handled by [Shopify managed installation](https://shopify.dev/docs/apps/auth/installation#shopify-managed-installation)

## 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 Down Expand Up @@ -94,7 +99,7 @@ end

```

### Authorization Code Grant Flow
### Authorization Code Grant
##### Steps
1. [Add a route to start OAuth](#1-add-a-route-to-start-oauth)
2. [Add an Oauth callback route](#2-add-an-oauth-callback-route)
Expand Down Expand Up @@ -265,9 +270,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 can only use the client credentials grant when building apps for your own organization.
> [!WARNING]
> [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.
#### 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
52 changes: 52 additions & 0 deletions test/auth/client_credentials_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# typed: false
# frozen_string_literal: true

require_relative "../test_helper"

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

@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: 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 33e5cf8

Please sign in to comment.