omniauth-shopify-app 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/build.yml +25 -0
- data/.gitignore +4 -0
- data/Gemfile +8 -0
- data/README.md +107 -0
- data/Rakefile +9 -0
- data/example/Gemfile +6 -0
- data/example/config.ru +68 -0
- data/lib/omniauth/shopify/encryptor.rb +121 -0
- data/lib/omniauth/shopify/oauth_session.rb +81 -0
- data/lib/omniauth/shopify/version.rb +5 -0
- data/lib/omniauth/shopify.rb +3 -0
- data/lib/omniauth/strategies/shopify.rb +310 -0
- data/lib/omniauth-shopify-app.rb +1 -0
- data/omniauth-shopify-app.gemspec +32 -0
- data/test/integration_test.rb +486 -0
- data/test/test_helper.rb +16 -0
- metadata +194 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: e0adc6d98970d118184067f144d6cc6d07a9ec346e6add26b1b3bf97bf2d6970
|
4
|
+
data.tar.gz: db9f41aae849ffd1a55559a85258973ab4af59554a20f1a4fda7f88c26b0bf91
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: cb171fd3fe0eeb681215d60302c8f1d11af051b08337871a3eacc17713e69374c71196bed98e8b643aaff1b9700ad1a648b609502d4bcbd70c6817e8064055fd
|
7
|
+
data.tar.gz: '080bfe5fc359bafd7633308930dbd78074628af138748faa01c7f79a75f0188a2c944cc072533ee965de64f20f72805fdf2b91caded051fdeb1db064f4249d2a'
|
@@ -0,0 +1,25 @@
|
|
1
|
+
name: CI
|
2
|
+
|
3
|
+
on:
|
4
|
+
push:
|
5
|
+
|
6
|
+
jobs:
|
7
|
+
build:
|
8
|
+
runs-on: ubuntu-latest
|
9
|
+
name: Ruby ${{ matrix.version }}
|
10
|
+
strategy:
|
11
|
+
matrix:
|
12
|
+
version: [2.7, 3.0, 3.1]
|
13
|
+
|
14
|
+
steps:
|
15
|
+
- uses: actions/checkout@v2
|
16
|
+
- name: Set up Ruby ${{ matrix.version }}
|
17
|
+
uses: ruby/setup-ruby@v1
|
18
|
+
with:
|
19
|
+
ruby-version: ${{ matrix.version }}
|
20
|
+
bundler-cache: true
|
21
|
+
- name: Install dependencies
|
22
|
+
run: bundle
|
23
|
+
- name: Run Tests
|
24
|
+
run: bundle exec rake
|
25
|
+
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,107 @@
|
|
1
|
+
# OmniAuth Shopify
|
2
|
+
|
3
|
+
Shopify OAuth2 Strategy for OmniAuth 1.0.
|
4
|
+
|
5
|
+
## Installing
|
6
|
+
|
7
|
+
Add to your `Gemfile`:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
gem 'omniauth-shopify-app'
|
11
|
+
```
|
12
|
+
|
13
|
+
Then `bundle install`.
|
14
|
+
|
15
|
+
## Usage
|
16
|
+
|
17
|
+
`OmniAuth::Strategies::Shopify` is simply a Rack middleware. Read [the OmniAuth 1.0 docs](https://github.com/intridea/omniauth) for detailed instructions.
|
18
|
+
|
19
|
+
Here's a quick example, adding the middleware to a Rails app in `config/initializers/omniauth.rb`:
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
Rails.application.config.middleware.use OmniAuth::Builder do
|
23
|
+
provider :shopify, ENV['SHOPIFY_API_KEY'], ENV['SHOPIFY_SHARED_SECRET']
|
24
|
+
end
|
25
|
+
```
|
26
|
+
|
27
|
+
Authenticate the user by having them visit /auth/shopify with a `shop` query parameter of their shop's myshopify.com domain. For example, the following form could be used
|
28
|
+
|
29
|
+
```html
|
30
|
+
<form action="/auth/shopify" method="get">
|
31
|
+
<label for="shop">Enter your store's URL:</label>
|
32
|
+
<input type="text" name="shop" placeholder="your-shop-url.myshopify.com">
|
33
|
+
<button type="submit">Log In</button>
|
34
|
+
</form>
|
35
|
+
```
|
36
|
+
|
37
|
+
Or without form `/auth/shopify?shop=your-shop-url.myshopify.com`
|
38
|
+
Alternatively you can put shop parameter to session as [Shopify App](https://github.com/Shopify/shopify_app) do
|
39
|
+
|
40
|
+
```ruby
|
41
|
+
session['shopify.omniauth_params'] = { shop: params[:shop] }
|
42
|
+
```
|
43
|
+
|
44
|
+
And finally it's possible to use your own query parameter by overriding default setup method. For example, like below:
|
45
|
+
|
46
|
+
```ruby
|
47
|
+
Rails.application.config.middleware.use OmniAuth::Builder do
|
48
|
+
provider :shopify,
|
49
|
+
ENV['SHOPIFY_API_KEY'],
|
50
|
+
ENV['SHOPIFY_SHARED_SECRET'],
|
51
|
+
option :setup, proc { |env|
|
52
|
+
strategy = env['omniauth.strategy']
|
53
|
+
|
54
|
+
|
55
|
+
|
56
|
+
site = if strategy.request.params['site']
|
57
|
+
"https://#{strategy.request.params['site']}"
|
58
|
+
else
|
59
|
+
''
|
60
|
+
end
|
61
|
+
|
62
|
+
env['omniauth.strategy'].options[:client_options][:site] = site
|
63
|
+
}
|
64
|
+
```
|
65
|
+
|
66
|
+
## Configuring
|
67
|
+
|
68
|
+
### Scope
|
69
|
+
|
70
|
+
You can configure the scope, which you pass in to the `provider` method via a `Hash`:
|
71
|
+
|
72
|
+
* `scope`: A comma-separated list of permissions you want to request from the user. See [the Shopify API docs](http://docs.shopify.com/api/tutorials/oauth) for a full list of available permissions.
|
73
|
+
|
74
|
+
For example, to request `read_products`, `read_orders` and `write_content` permissions and display the authentication page:
|
75
|
+
|
76
|
+
```ruby
|
77
|
+
Rails.application.config.middleware.use OmniAuth::Builder do
|
78
|
+
provider :shopify, ENV['SHOPIFY_API_KEY'], ENV['SHOPIFY_SHARED_SECRET'], :scope => 'read_products,read_orders,write_content'
|
79
|
+
end
|
80
|
+
```
|
81
|
+
|
82
|
+
### Online Access
|
83
|
+
|
84
|
+
Shopify offers two different types of access tokens: [online access and offline access](https://help.shopify.com/api/getting-started/authentication/oauth/api-access-modes). You can configure for online-access by passing the `per_user_permissions` option:
|
85
|
+
|
86
|
+
```
|
87
|
+
Rails.application.config.middleware.use OmniAuth::Builder do
|
88
|
+
provider :shopify, ENV['SHOPIFY_API_KEY'],
|
89
|
+
ENV['SHOPIFY_SHARED_SECRET'],
|
90
|
+
:scope => 'read_orders',
|
91
|
+
:per_user_permissions => true
|
92
|
+
end
|
93
|
+
```
|
94
|
+
|
95
|
+
## Authentication Hash
|
96
|
+
|
97
|
+
Here's an example *Authentication Hash* available in `request.env['omniauth.auth']`:
|
98
|
+
|
99
|
+
```ruby
|
100
|
+
{
|
101
|
+
:provider => 'shopify',
|
102
|
+
:uid => 'example.myshopify.com',
|
103
|
+
:credentials => {
|
104
|
+
:token => 'afasd923kjh0934kf', # OAuth 2.0 access_token, which you store and use to authenticate API requests
|
105
|
+
}
|
106
|
+
}
|
107
|
+
```
|
data/Rakefile
ADDED
data/example/Gemfile
ADDED
data/example/config.ru
ADDED
@@ -0,0 +1,68 @@
|
|
1
|
+
require 'bundler/setup'
|
2
|
+
require 'sinatra/base'
|
3
|
+
require 'active_support/core_ext/hash'
|
4
|
+
require 'omniauth-shopify-app'
|
5
|
+
|
6
|
+
SCOPE = 'read_products,read_orders,read_customers,write_shipping'
|
7
|
+
SHOPIFY_API_KEY = ENV['SHOPIFY_API_KEY']
|
8
|
+
SHOPIFY_SHARED_SECRET = ENV['SHOPIFY_SHARED_SECRET']
|
9
|
+
|
10
|
+
unless SHOPIFY_API_KEY && SHOPIFY_SHARED_SECRET
|
11
|
+
abort("SHOPIFY_API_KEY and SHOPIFY_SHARED_SECRET environment variables must be set")
|
12
|
+
end
|
13
|
+
|
14
|
+
class App < Sinatra::Base
|
15
|
+
get '/' do
|
16
|
+
<<-HTML
|
17
|
+
<html>
|
18
|
+
<head>
|
19
|
+
<title>Shopify Oauth2</title>
|
20
|
+
</head>
|
21
|
+
<body>
|
22
|
+
<form action="/auth/shopify" method="get">
|
23
|
+
<label for="shop">Enter your store's URL:</label>
|
24
|
+
<input type="text" name="shop" placeholder="your-shop-url.myshopify.com">
|
25
|
+
<button type="submit">Log In</button>
|
26
|
+
</form>
|
27
|
+
</body>
|
28
|
+
</html>
|
29
|
+
HTML
|
30
|
+
end
|
31
|
+
|
32
|
+
get '/auth/:provider/callback' do
|
33
|
+
<<-HTML
|
34
|
+
<html>
|
35
|
+
<head>
|
36
|
+
<title>Shopify Oauth2</title>
|
37
|
+
</head>
|
38
|
+
<body>
|
39
|
+
<h3>Authorized</h3>
|
40
|
+
<p>Shop: #{request.env['omniauth.auth'].uid}</p>
|
41
|
+
<p>Token: #{request.env['omniauth.auth']['credentials']['token']}</p>
|
42
|
+
</body>
|
43
|
+
</html>
|
44
|
+
HTML
|
45
|
+
end
|
46
|
+
|
47
|
+
get '/auth/failure' do
|
48
|
+
<<-HTML
|
49
|
+
<html>
|
50
|
+
<head>
|
51
|
+
<title>Shopify Oauth2</title>
|
52
|
+
</head>
|
53
|
+
<body>
|
54
|
+
<h3>Failed Authorization</h3>
|
55
|
+
<p>Message: #{params[:message]}</p>
|
56
|
+
</body>
|
57
|
+
</html>
|
58
|
+
HTML
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
use Rack::Session::Cookie, secret: SecureRandom.hex(64)
|
63
|
+
|
64
|
+
use OmniAuth::Builder do
|
65
|
+
provider :shopify, SHOPIFY_API_KEY, SHOPIFY_SHARED_SECRET, :scope => SCOPE
|
66
|
+
end
|
67
|
+
|
68
|
+
run App.new
|
@@ -0,0 +1,121 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
|
3
|
+
module OmniAuth
|
4
|
+
module Shopify
|
5
|
+
# Encrypts messages with authentication
|
6
|
+
#
|
7
|
+
# The use of authentication is essential to avoid Chosen Ciphertext
|
8
|
+
# Attacks. By using this in an encrypt then MAC form, we avoid some
|
9
|
+
# attacks such as e.g. being used as a CBC padding oracle to decrypt
|
10
|
+
# the ciphertext.
|
11
|
+
class Encryptor
|
12
|
+
# Create the encryptor
|
13
|
+
#
|
14
|
+
# Pass in the secret, which should be at least 32-bytes worth of
|
15
|
+
# entropy, e.g. a string generated by `SecureRandom.hex(32)`.
|
16
|
+
# This also allows specification of the algorithm for the cipher
|
17
|
+
# and MAC. But don't change that unless you're very sure.
|
18
|
+
def initialize(secret, cipher = 'aes-256-cbc', hmac = 'SHA256')
|
19
|
+
@cipher = cipher
|
20
|
+
@hmac = hmac
|
21
|
+
|
22
|
+
# use the HMAC to derive two independent keys for the encryption and
|
23
|
+
# authentication of ciphertexts It is bad practice to use the same key
|
24
|
+
# for encryption and authentication. This also allows us to use all
|
25
|
+
# of the entropy in a long key (e.g. 64 hex bytes) when straight
|
26
|
+
# assignement would could result in assigning a key with a much
|
27
|
+
# reduced key space. Also, the personalisation strings further help
|
28
|
+
# reduce the possibility of key reuse by ensuring it should be unique
|
29
|
+
# to this gem, even with shared secrets.
|
30
|
+
@encryption_key = hmac("EncryptedCookie Encryption", secret)
|
31
|
+
@authentication_key = hmac("EncryptedCookie Authentication", secret)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Encrypts message
|
35
|
+
#
|
36
|
+
# Returns the base64 encoded ciphertext plus IV. In addtion, the
|
37
|
+
# message is prepended with a MAC code to prevent chosen ciphertext
|
38
|
+
# attacks.
|
39
|
+
def encrypt(message)
|
40
|
+
# encrypt the message
|
41
|
+
encrypted = encrypt_message(message)
|
42
|
+
|
43
|
+
[authenticate_message(encrypted) + encrypted].pack('m0')
|
44
|
+
end
|
45
|
+
|
46
|
+
# decrypts base64 encoded ciphertext
|
47
|
+
#
|
48
|
+
# First, it checks the message tag and returns nil if that fails to verify.
|
49
|
+
# Otherwise, the data is passed on to the AES function for decryption.
|
50
|
+
def decrypt(ciphertext)
|
51
|
+
ciphertext = ciphertext.unpack('m').first
|
52
|
+
tag = ciphertext[0, hmac_length]
|
53
|
+
ciphertext = ciphertext[hmac_length..-1]
|
54
|
+
|
55
|
+
# make sure we actually had enough data for the tag too.
|
56
|
+
if tag && ciphertext && verify_message(tag, ciphertext)
|
57
|
+
decrypt_ciphertext(ciphertext)
|
58
|
+
else
|
59
|
+
nil
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
# HMAC digest of the message using the given secret
|
66
|
+
def hmac(secret, message)
|
67
|
+
OpenSSL::HMAC.digest(@hmac, secret, message)
|
68
|
+
end
|
69
|
+
|
70
|
+
def hmac_length
|
71
|
+
OpenSSL::Digest.new(@hmac).size
|
72
|
+
end
|
73
|
+
|
74
|
+
# returns the message authentication tag
|
75
|
+
#
|
76
|
+
# This is computed as HMAC(authentication_key, message)
|
77
|
+
def authenticate_message(message)
|
78
|
+
hmac(@authentication_key, message)
|
79
|
+
end
|
80
|
+
|
81
|
+
# verifies the message
|
82
|
+
#
|
83
|
+
# This does its best to be constant time, by use of the rack secure compare
|
84
|
+
# function.
|
85
|
+
def verify_message(tag, message)
|
86
|
+
own_tag = authenticate_message(message)
|
87
|
+
Rack::Utils.secure_compare(tag, own_tag)
|
88
|
+
end
|
89
|
+
|
90
|
+
# Encrypt
|
91
|
+
#
|
92
|
+
# Encrypts the given message with a random IV, then returns the ciphertext
|
93
|
+
# with the IV prepended.
|
94
|
+
def encrypt_message(message)
|
95
|
+
aes = OpenSSL::Cipher.new(@cipher).encrypt
|
96
|
+
aes.key = @encryption_key
|
97
|
+
iv = aes.random_iv
|
98
|
+
aes.iv = iv
|
99
|
+
iv + (aes.update(message) << aes.final)
|
100
|
+
end
|
101
|
+
|
102
|
+
# Decrypt
|
103
|
+
#
|
104
|
+
# Pulls the IV off the front of the message and decrypts. Catches
|
105
|
+
# OpenSSL errors and returns nil. But this should never happen, as the
|
106
|
+
# verify method should catch all corrupted ciphertexts.
|
107
|
+
def decrypt_ciphertext(ciphertext)
|
108
|
+
aes = OpenSSL::Cipher.new(@cipher).decrypt
|
109
|
+
aes.key = @encryption_key
|
110
|
+
iv = ciphertext[0, aes.iv_len]
|
111
|
+
aes.iv = iv
|
112
|
+
crypted_text = ciphertext[aes.iv_len..-1]
|
113
|
+
return nil if crypted_text.nil? || iv.nil?
|
114
|
+
aes.update(crypted_text) << aes.final
|
115
|
+
rescue
|
116
|
+
nil
|
117
|
+
end
|
118
|
+
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require 'rack'
|
2
|
+
require_relative './encryptor'
|
3
|
+
|
4
|
+
# Copy and modify from https://github.com/cvonkleist/encrypted_cookie
|
5
|
+
|
6
|
+
module OmniAuth
|
7
|
+
module Shopify
|
8
|
+
class OAuthSession
|
9
|
+
EXPIRES = '_encrypted_cookie_expires_'
|
10
|
+
KEY = 'shopify_oauth_session'
|
11
|
+
|
12
|
+
def initialize(app, options={})
|
13
|
+
@app = app
|
14
|
+
@key = options[:key] || KEY
|
15
|
+
@secret = options[:secret]
|
16
|
+
fail "Error! A secret is required to use encrypted cookies. Do something like this:\n\nuse OmniAuth::Shopify::OauthSession, :secret => YOUR_VERY_LONG_VERY_RANDOM_SECRET_KEY_HERE" unless @secret
|
17
|
+
@default_options = {:domain => nil,
|
18
|
+
:path => "/",
|
19
|
+
:time_to_live => 1800,
|
20
|
+
:expire_after => nil}.merge(options)
|
21
|
+
@encryptor = Encryptor.new(@secret)
|
22
|
+
end
|
23
|
+
|
24
|
+
def call(env)
|
25
|
+
load_session(env)
|
26
|
+
status, headers, body = @app.call(env)
|
27
|
+
commit_session(env, status, headers, body)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def remove_expiration(session_data)
|
33
|
+
expires = session_data.delete(EXPIRES)
|
34
|
+
if expires and expires < Time.now
|
35
|
+
session_data.clear
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def load_session(env)
|
40
|
+
request = Rack::Request.new(env)
|
41
|
+
env["rack.#{@key}.options"] = @default_options.dup
|
42
|
+
|
43
|
+
session_data = request.cookies[@key]
|
44
|
+
session_data = @encryptor.decrypt(session_data)
|
45
|
+
session_data = Marshal.load(session_data)
|
46
|
+
remove_expiration(session_data)
|
47
|
+
|
48
|
+
env["rack.#{@key}"] = session_data
|
49
|
+
rescue
|
50
|
+
env["rack.#{@key}"] = Hash.new
|
51
|
+
end
|
52
|
+
|
53
|
+
def add_expiration(session_data, options)
|
54
|
+
if options[:time_to_live] && !session_data.key?(EXPIRES)
|
55
|
+
expires = Time.now + options[:time_to_live]
|
56
|
+
session_data.merge!({EXPIRES => expires})
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def commit_session(env, status, headers, body)
|
61
|
+
options = env["rack.#{@key}.options"]
|
62
|
+
|
63
|
+
session_data = env["rack.#{@key}"]
|
64
|
+
add_expiration(session_data, options)
|
65
|
+
session_data = Marshal.dump(session_data)
|
66
|
+
session_data = @encryptor.encrypt(session_data)
|
67
|
+
|
68
|
+
if session_data.size > (4096 - @key.size)
|
69
|
+
env["rack.errors"].puts("Warning! ShopifyOAuthSession data size exceeds 4K. Content dropped.")
|
70
|
+
else
|
71
|
+
cookie = Hash.new
|
72
|
+
cookie[:value] = session_data
|
73
|
+
cookie[:expires] = Time.now + options[:expire_after] unless options[:expire_after].nil?
|
74
|
+
Rack::Utils.set_cookie_header!(headers, @key, cookie.merge(options))
|
75
|
+
end
|
76
|
+
|
77
|
+
[status, headers, body]
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|