omniauth-shopify-app 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|