Skip to content

Commit

Permalink
Merge pull request #1832 from Shopify/support-id-token
Browse files Browse the repository at this point in the history
Support id_token from URL params
  • Loading branch information
zzooeeyy committed Apr 24, 2024
2 parents ea884a6 + d05d855 commit d5a2394
Show file tree
Hide file tree
Showing 17 changed files with 389 additions and 128 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
Unreleased
----------
* Bumps `shopify_api` to `14.3.0` [1832](https://github.com/Shopify/shopify_app/pull/1832)
* Support `id_token` from URL param [1832](https://github.com/Shopify/shopify_app/pull/1832)
* Extracted controller concern `WithShopifyIdToken`
* This concern provides a method `shopify_id_token` to retrieve the Shopify Id token from either the authorization header or the URL param `id_token`.
* `ShopifyApp::JWTMiddleware` supports retrieving session token from URL param `id_token`
* `ShopifyApp::JWTMiddleware` returns early if the app is not embedded to avoid unnecessary JWT verification
* `LoginProtection` now uses `WithShopifyIdToken` concern to retrieve the Shopify Id token, thus accepting the session token from the URL param `id_token`
* Marking `ShopifyApp::JWT` to be deprecated in version 23.0.0 [1832](https://github.com/Shopify/shopify_app/pull/1832), use `ShopifyAPI::Auth::JwtPayload` instead.

22.1.0 (April 9,2024)
----------
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ PATH
jwt (>= 2.2.3)
rails (> 5.2.1)
redirect_safely (~> 1.0)
shopify_api (>= 14.2.0, < 15.0)
shopify_api (>= 14.3.0, < 15.0)
sprockets-rails (>= 2.0.0)

GEM
Expand Down Expand Up @@ -217,7 +217,7 @@ GEM
ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5)
securerandom (0.2.2)
shopify_api (14.2.0)
shopify_api (14.3.0)
activesupport
concurrent-ruby
hash_diff
Expand Down
23 changes: 0 additions & 23 deletions docs/Troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,29 +92,6 @@ Edit `config/initializer/shopify_app.rb` and ensure the following configurations
+ config.shop_session_repository = 'Shop'
```

#### Inspect server logs

If you have checked the configurations above, and the app is still using cookies, then it is possible that the `shopify_app` gem defaulted to relying on cookies. This would happen when your browser allows third-party cookies and a session token was not successfully found as part of your request.

In this case, check the server logs to see if the session token was invalid:

```los
[ShopifyApp::JWT] Failed to validate JWT: [JWT::<Error>] <Failure message>
```

*Example*

```
[ShopifyApp::JWT] Failed to validate JWT: [JWT::ImmatureSignature] Signature nbf has not been reached
```

**Note:** In a local development environment, you may want to temporarily update your `Gemfile` to point to a local instance of the `shopify_app` library instad of an installed gem. This will enable you to use a debugging tool like `byebug` to debug the library.

```diff
- gem 'shopify_app', '~> 14.2'
+ gem 'shopify_app', path: '/path/to/shopify_app'
```

### My app can't make requests to the Shopify API

> **Note:** Session tokens cannot be used to make authenticated requests to the Shopify API. Learn more about authenticating your backend requests to Shopify APIs at [Shopify API authentication](https://shopify.dev/concepts/about-apis/authentication).
Expand Down
6 changes: 6 additions & 0 deletions docs/Upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ If you have overwritten these methods in your callback controller to modify the
update your app to use configurable option `config.custom_post_authenticate_tasks` instead. See [post authenticate tasks](/docs/shopify_app/authentication.md#post-authenticate-tasks)
for more information.

#### (v23.0.0) - Deprecated "ShopifyApp::JWT" class
The `ShopifyApp::JWT` class has been deprecated in `v23.0.0`. Use [ShopifyAPI::Auth::JwtPayload](https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/auth/jwt_payload.rb)
class from the `shopify_api` gem instead. A search and replace should be enough for this migration.
- `ShopifyAPI::Auth::JwtPayload` is a superset of the `ShopifyApp::JWT` class, and contains methods that were available in `ShopifyApp::JWT`.
- `ShopifyAPI::Auth::JwtPayload` raises `ShopifyAPI::Errors::InvalidJwtTokenError` if the token is invalid.

## Upgrading to `v22.0.0`
#### Dropped support for Ruby 2.x
Support for Ruby 2.x has been dropped as it is no longer supported. You'll need to upgrade to 3.x.x
Expand Down
1 change: 1 addition & 0 deletions lib/shopify_app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def self.use_webpacker?
require "shopify_app/controller_concerns/app_proxy_verification"
require "shopify_app/controller_concerns/webhook_verification"
require "shopify_app/controller_concerns/token_exchange"
require "shopify_app/controller_concerns/with_shopify_id_token"

# Auth helpers
require "shopify_app/auth/post_authenticate_tasks"
Expand Down
2 changes: 1 addition & 1 deletion lib/shopify_app/auth/token_exchange.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def initialize(id_token)
end

def perform
domain = ShopifyApp::JWT.new(id_token).shopify_domain
domain = ShopifyAPI::Auth::JwtPayload.new(id_token).shopify_domain

Logger.info("Performing Token Exchange for [#{domain}] - (Offline)")
session = exchange_token(
Expand Down
11 changes: 6 additions & 5 deletions lib/shopify_app/controller_concerns/login_protection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ module LoginProtection
end

rescue_from ShopifyAPI::Errors::HttpResponseError, with: :handle_http_error
include ShopifyApp::WithShopifyIdToken
end

ACCESS_TOKEN_REQUIRED_HEADER = "X-Shopify-API-Request-Failure-Unauthorized"
Expand Down Expand Up @@ -53,7 +54,7 @@ def current_shopify_session
@current_shopify_session ||= begin
cookie_name = ShopifyAPI::Auth::Oauth::SessionCookie::SESSION_COOKIE_NAME
load_current_session(
auth_header: request.headers["HTTP_AUTHORIZATION"],
shopify_id_token: shopify_id_token,
cookies: { cookie_name => cookies.encrypted[cookie_name] },
is_online: online_token_configured?,
)
Expand Down Expand Up @@ -94,8 +95,8 @@ def add_top_level_redirection_headers(url: nil, ignore_response_code: false)
params[:shop] = if current_shopify_session
current_shopify_session.shop

elsif (matches = request.headers["HTTP_AUTHORIZATION"]&.match(/^Bearer (.+)$/))
jwt_payload = ShopifyAPI::Auth::JwtPayload.new(T.must(matches[1]))
elsif shopify_id_token
jwt_payload = ShopifyAPI::Auth::JwtPayload.new(shopify_id_token)
jwt_payload.shop
end
end
Expand Down Expand Up @@ -273,10 +274,10 @@ def user_session_expected?
online_token_configured?
end

def load_current_session(auth_header: nil, cookies: nil, is_online: false)
def load_current_session(shopify_id_token: nil, cookies: nil, is_online: false)
return ShopifyAPI::Context.load_private_session if ShopifyAPI::Context.private?

session_id = ShopifyAPI::Utils::SessionUtils.current_session_id(auth_header, cookies, is_online)
session_id = ShopifyAPI::Utils::SessionUtils.current_session_id(shopify_id_token, cookies, is_online)
return nil unless session_id

ShopifyApp::SessionRepository.load_session(session_id)
Expand Down
21 changes: 8 additions & 13 deletions lib/shopify_app/controller_concerns/token_exchange.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ module TokenExchange
extend ActiveSupport::Concern
include ShopifyApp::AdminAPI::WithTokenRefetch

included do
include ShopifyApp::WithShopifyIdToken
end

INVALID_SHOPIFY_ID_TOKEN_ERRORS = [
ShopifyAPI::Errors::CookieNotFoundError,
ShopifyAPI::Errors::MissingJwtTokenError,
ShopifyAPI::Errors::InvalidJwtTokenError,
].freeze

Expand Down Expand Up @@ -35,10 +39,9 @@ def current_shopify_session
end

def current_shopify_session_id
@current_shopify_session_id ||= ShopifyAPI::Utils::SessionUtils.current_session_id(
request.headers["HTTP_AUTHORIZATION"],
nil,
online_token_configured?,
@current_shopify_session_id ||= ShopifyAPI::Utils::SessionUtils.session_id_from_shopify_id_token(
id_token: shopify_id_token,
online: online_token_configured?,
)
end

Expand All @@ -55,14 +58,6 @@ def retrieve_session_from_token_exchange
ShopifyApp::Auth::TokenExchange.perform(shopify_id_token)
end

def shopify_id_token
@shopify_id_token ||= id_token_header
end

def id_token_header
request.headers["HTTP_AUTHORIZATION"]&.match(/^Bearer (.+)$/)&.[](1)
end

def respond_to_invalid_shopify_id_token
return redirect_to_bounce_page if request.headers["HTTP_AUTHORIZATION"].blank?

Expand Down
26 changes: 26 additions & 0 deletions lib/shopify_app/controller_concerns/with_shopify_id_token.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

module ShopifyApp
module WithShopifyIdToken
extend ActiveSupport::Concern

def shopify_id_token
@shopify_id_token ||= id_token_from_request_env || id_token_from_authorization_header || id_token_from_url_param
end

private

def id_token_from_request_env
# This is set from ShopifyApp::JWTMiddleware
request.env["jwt.token"]
end

def id_token_from_authorization_header
request.headers["HTTP_AUTHORIZATION"]&.match(/^Bearer (.+)$/)&.[](1)
end

def id_token_from_url_param
params["id_token"]
end
end
end
22 changes: 13 additions & 9 deletions lib/shopify_app/middleware/jwt_middleware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@

module ShopifyApp
class JWTMiddleware
TOKEN_REGEX = /^Bearer\s+(.*?)$/
TOKEN_REGEX = /^Bearer (.+)$/
ID_TOKEN_QUERY_PARAM = "id_token"

def initialize(app)
@app = app
end

def call(env)
return call_next(env) unless authorization_header(env)
return call_next(env) unless ShopifyApp.configuration.embedded_app?

token = extract_token(env)
token = token_from_authorization_header(env) || token_from_query_string(env)
return call_next(env) unless token

set_env_variables(token, env)
Expand All @@ -24,21 +25,24 @@ def call_next(env)
@app.call(env)
end

def authorization_header(env)
env["HTTP_AUTHORIZATION"]
def token_from_authorization_header(env)
env["HTTP_AUTHORIZATION"]&.match(TOKEN_REGEX)&.[](1)
end

def extract_token(env)
match = authorization_header(env).match(TOKEN_REGEX)
match && match[1]
def token_from_query_string(env)
Rack::Utils.parse_nested_query(env["QUERY_STRING"])[ID_TOKEN_QUERY_PARAM]
end

def set_env_variables(token, env)
jwt = ShopifyApp::JWT.new(token)
jwt = ShopifyAPI::Auth::JwtPayload.new(token)

env["jwt.token"] = token
env["jwt.shopify_domain"] = jwt.shopify_domain
env["jwt.shopify_user_id"] = jwt.shopify_user_id
env["jwt.expire_at"] = jwt.expire_at
rescue ShopifyAPI::Errors::InvalidJwtTokenError
# ShopifyApp::JWT did not raise any exceptions, ensuring behaviour does not change
nil
end
end
end
9 changes: 9 additions & 0 deletions lib/shopify_app/session/jwt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class JWT
]

def initialize(token)
warn_deprecation
@token = token
set_payload
end
Expand Down Expand Up @@ -60,5 +61,13 @@ def validate_payload(payload)

payload
end

def warn_deprecation
message = <<~EOS
"ShopifyApp::JWT will be deprecated, use ShopifyAPI::Auth::JwtPayload to parse JWT token instead."
EOS

ShopifyApp::Logger.deprecated(message, "23.0.0")
end
end
end
2 changes: 1 addition & 1 deletion shopify_app.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Gem::Specification.new do |s|
s.add_runtime_dependency("jwt", ">= 2.2.3")
s.add_runtime_dependency("rails", "> 5.2.1")
s.add_runtime_dependency("redirect_safely", "~> 1.0")
s.add_runtime_dependency("shopify_api", ">= 14.2.0", "< 15.0")
s.add_runtime_dependency("shopify_api", ">= 14.3.0", "< 15.0")
s.add_runtime_dependency("sprockets-rails", ">= 2.0.0")

s.add_development_dependency("byebug")
Expand Down
33 changes: 27 additions & 6 deletions test/shopify_app/controller_concerns/login_protection_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ class LoginProtectionControllerTest < ActionController::TestCase
ShopifyApp::SessionRepository.shop_storage = ShopifyApp::InMemoryShopSessionStore
ShopifyApp::SessionRepository.user_storage = ShopifyApp::InMemoryUserSessionStore

@token = "Bearer Grylls da token"
@token = "Grylls da token"
@token_in_auth_header = "Bearer #{@token}"

@session = ShopifyAPI::Auth::Session.new(id: "1", shop: @shop)
ShopifyApp::SessionRepository.store_shop_session(@session)

Expand Down Expand Up @@ -93,7 +95,7 @@ class LoginProtectionControllerTest < ActionController::TestCase
)

with_application_test_routes do
request.headers["HTTP_AUTHORIZATION"] = @token
request.headers["HTTP_AUTHORIZATION"] = @token_in_auth_header

::ShopifyAPI::Utils::SessionUtils.expects(:current_session_id)
.with(
Expand Down Expand Up @@ -123,7 +125,7 @@ class LoginProtectionControllerTest < ActionController::TestCase
)

with_application_test_routes do
request.headers["HTTP_AUTHORIZATION"] = @token
request.headers["HTTP_AUTHORIZATION"] = @token_in_auth_header

::ShopifyAPI::Utils::SessionUtils.expects(:current_session_id)
.with(
Expand All @@ -148,7 +150,7 @@ class LoginProtectionControllerTest < ActionController::TestCase

test "#current_shopify_session loads online session if user session expected" do
with_application_test_routes do
request.headers["HTTP_AUTHORIZATION"] = @token
request.headers["HTTP_AUTHORIZATION"] = @token_in_auth_header

::ShopifyAPI::Utils::SessionUtils.expects(:current_session_id)
.with(
Expand All @@ -165,7 +167,7 @@ class LoginProtectionControllerTest < ActionController::TestCase
test "#current_shopify_session loads offline session if user session unexpected" do
ShopifyApp::SessionRepository.user_storage = nil

request.headers["HTTP_AUTHORIZATION"] = @token
request.headers["HTTP_AUTHORIZATION"] = @token_in_auth_header

ShopifyAPI::Utils::SessionUtils.expects(:current_session_id)
.with(
Expand All @@ -180,6 +182,25 @@ class LoginProtectionControllerTest < ActionController::TestCase
end
end

test "#current_shopify_session loads session from URL param id_token" do
ShopifyApp::SessionRepository.user_storage = nil

request.headers["HTTP_AUTHORIZATION"] = nil

ShopifyAPI::Utils::SessionUtils.expects(:current_session_id)
.with(
@token,
{ ShopifyAPI::Auth::Oauth::SessionCookie::SESSION_COOKIE_NAME => nil },
false,
)
.returns(@session.id)
ShopifyAPI::Context.expects(:activate_session)

with_application_test_routes do
get :index, params: { id_token: @token }
end
end

test "#current_shopify_session is nil if token is invalid" do
request.headers["HTTP_AUTHORIZATION"] = "Bearer invalid"

Expand Down Expand Up @@ -274,7 +295,7 @@ class LoginProtectionControllerTest < ActionController::TestCase
ShopifyAPI::Context.stubs(:scope).returns(ShopifyAPI::Auth::AuthScopes.new(["scope1"]))

cookies.encrypted[ShopifyAPI::Auth::Oauth::SessionCookie::SESSION_COOKIE_NAME] = "cookie"
request.headers["HTTP_AUTHORIZATION"] = @token
request.headers["HTTP_AUTHORIZATION"] = @token_in_auth_header

ShopifyApp::SessionRepository.expects(:load_session).returns(
ShopifyAPI::Auth::Session.new(shop: "some-shop", scope: ["scope1", "scope2"]),
Expand Down

0 comments on commit d5a2394

Please sign in to comment.