omniauth-shopline 0.1.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/CHANGELOG.md +78 -0
- data/LICENSE.txt +21 -0
- data/README.md +198 -0
- data/lib/omniauth/strategies/shopline.rb +245 -0
- data/lib/omniauth-shopline/version.rb +7 -0
- data/lib/omniauth-shopline.rb +4 -0
- data/omniauth-shopline.gemspec +42 -0
- metadata +91 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 01e751e6d88dcef0f68c96602a5d3a335602d9deb45a946d5c3c077492bac924
|
|
4
|
+
data.tar.gz: f3fad501ab98759d9a4721b715488ea48ba50a54992848df9234eaef3ce55b2a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ff0a41880509b2cce52b843a03017c486ddb167b28889b171a39e406b049b731a44d8652b88bfe78fb45e2b8494173fa8e485a45582063ec737c2a36fd280755
|
|
7
|
+
data.tar.gz: c87fa791adf2c9e92299256af917a56736a0084acfb191ab4be080a37416f632cc6f3dc1702f6ad353903251426c5677371cfeeb0a8fe6e245b2f58c0b364894
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file. Each version heading
|
|
4
|
+
links to its [GitHub release](https://github.com/dropstream/omniauth-shopline/releases),
|
|
5
|
+
where the notes are generated from the merged pull requests.
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-08-27
|
|
8
|
+
|
|
9
|
+
First release published to RubyGems.org. Everything below is relative to the state of
|
|
10
|
+
`master` before it, which is how the gem was installed until now (`git:` in a Gemfile).
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Runtime dependencies are declared as `omniauth >= 2.0, < 4` and
|
|
15
|
+
`omniauth-oauth2 >= 1.8, < 3`. A plugin's ceiling becomes a ceiling in every
|
|
16
|
+
application that installs it, so this allows the next major of both gems rather than
|
|
17
|
+
blocking consumers on the day one ships. The floors are where OmniAuth 2 support
|
|
18
|
+
actually begins — `omniauth-oauth2` 1.8.0 is its first release to require
|
|
19
|
+
`omniauth ~> 2.0` — and CI runs the suite against those floors as well as against the
|
|
20
|
+
newest resolvable versions.
|
|
21
|
+
- Requires Ruby >= 3.2, tested on 3.2 through 4.0.
|
|
22
|
+
- The app key and secret are read from the `client_id`/`client_secret` positional
|
|
23
|
+
arguments, with `app_key`/`app_secret` accepted as named options. Previously the
|
|
24
|
+
strategy read `options.app_key`, which the positional arguments never set, so every
|
|
25
|
+
request went out with an empty `appKey`.
|
|
26
|
+
- `client_options[:site]` is only derived from `handle` when it still holds the
|
|
27
|
+
`{handle}` placeholder, so an explicitly configured site now survives.
|
|
28
|
+
- Removed the unused `token_params` default (`grant_type: authorization_code`). The
|
|
29
|
+
token endpoint takes only `{"code": ...}`, so the option never reached SHOPLINE.
|
|
30
|
+
- Development toolchain: Bundler 4.x, `rake ~> 13.0`, `rspec ~> 3.13`, `webmock ~> 3.25`.
|
|
31
|
+
- Added GitHub Actions for CI, a weekly `bundle-audit` run, and releases published to
|
|
32
|
+
RubyGems.org from the GitHub Releases UI via trusted publishing (OIDC, no stored API
|
|
33
|
+
key).
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- `LICENSE.txt` (MIT) is declared in the gemspec, along with `required_ruby_version` and
|
|
38
|
+
the usual `homepage`/`changelog`/`rubygems_mfa_required` metadata.
|
|
39
|
+
- CSRF protection for the callback. SHOPLINE has no `state` parameter, so the nonce
|
|
40
|
+
travels in `customField` — the documented pass-through — and is verified against the
|
|
41
|
+
session. Disable with `verify_state: false`.
|
|
42
|
+
- Verification of the `sign` SHOPLINE appends to the callback query, computed the way
|
|
43
|
+
[SHOPLINE's own SDK](https://github.com/shoplineos/shopline-sdk-go/blob/main/client/sign.go)
|
|
44
|
+
computes it. Disable with `verify_signature: false`.
|
|
45
|
+
- `scope` in the credentials hash, which the README documented but the code did not
|
|
46
|
+
provide.
|
|
47
|
+
- `handle` is exposed as a reader on the strategy.
|
|
48
|
+
- Integration specs covering the request phase, the token exchange and its signature,
|
|
49
|
+
state and signature verification, the auth hash, and the `callback_url` overrides.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- The authorize URL put its query string in front of the `#` fragment
|
|
54
|
+
(`/admin/oauth-web/?appKey=...#/oauth/authorize`), because `OAuth2::Client#authorize_url`
|
|
55
|
+
cannot build a URL for a hash-routed page. SHOPLINE's admin page never read those
|
|
56
|
+
params. It is now built by hand as
|
|
57
|
+
`/admin/oauth-web/#/oauth/authorize?appKey=...`.
|
|
58
|
+
- The token request was signed with the sorted-parameter scheme SHOPLINE uses for GETs
|
|
59
|
+
(`appkey=..×tamp=..`). A POST is signed over the request body concatenated with
|
|
60
|
+
the millisecond timestamp, and the signed string is now the exact body that is sent.
|
|
61
|
+
- Every callback failed with `csrf_detected`: the request phase never wrote
|
|
62
|
+
`omniauth.state`, and SHOPLINE does not echo a `state` parameter, so
|
|
63
|
+
`OmniAuth::Strategies::OAuth2#callback_phase` rejected the callback before the token
|
|
64
|
+
exchange.
|
|
65
|
+
- `callback_url` no longer doubles a mount prefix. The override added `script_name` to
|
|
66
|
+
`callback_path`, but OmniAuth 2 moved `script_name` into `callback_path` itself, so a
|
|
67
|
+
strategy mounted under a prefix (for example Devise's `/users`) would have sent
|
|
68
|
+
`redirectUri=http://host/users/users/auth/shopline/callback`.
|
|
69
|
+
- A token response without an access token used to `fail!` from inside
|
|
70
|
+
`build_access_token` and hand the resulting Rack triple back as the access token. It
|
|
71
|
+
now raises `OAuth2::Error`, which OmniAuth turns into an `invalid_credentials` failure.
|
|
72
|
+
- The callback nonce and signature are compared in constant time.
|
|
73
|
+
- The token's `scope` reaches `OAuth2::AccessToken` under a string key. It was passed
|
|
74
|
+
with a symbol key, which `access_token.params['scope']` never found, so
|
|
75
|
+
`extra['scope']` was always `nil`.
|
|
76
|
+
- Development scripts in `bin/` and the spec suite are no longer packaged into the gem.
|
|
77
|
+
|
|
78
|
+
[0.1.0]: https://github.com/dropstream/omniauth-shopline/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Dropstream
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# OmniAuth SHOPLINE
|
|
2
|
+
|
|
3
|
+
An OmniAuth strategy for authenticating with [SHOPLINE](https://www.shopline.com) using
|
|
4
|
+
its [OAuth 2.0 app authorization flow](https://developer.shopline.com/docs/apps/api-instructions-for-use/app-authorization/).
|
|
5
|
+
|
|
6
|
+
Requires Ruby >= 3.2 and OmniAuth 2.x.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Add this line to your application's Gemfile:
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
gem 'omniauth-shopline'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
And then execute:
|
|
17
|
+
|
|
18
|
+
$ bundle
|
|
19
|
+
|
|
20
|
+
Or install it yourself as:
|
|
21
|
+
|
|
22
|
+
$ gem install omniauth-shopline
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
OmniAuth 2 only accepts `POST` for the request phase. In a Rails app, add
|
|
27
|
+
[`omniauth-rails_csrf_protection`](https://github.com/cookpad/omniauth-rails_csrf_protection)
|
|
28
|
+
to your Gemfile and link to the provider with `button_to` or `link_to ..., method: :post`:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
gem 'omniauth-rails_csrf_protection'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
Rails.application.config.middleware.use OmniAuth::Builder do
|
|
36
|
+
provider :shopline,
|
|
37
|
+
'app_key', 'app_secret',
|
|
38
|
+
handle: 'your-store',
|
|
39
|
+
scope: 'read_products,read_orders'
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`handle` is the store subdomain (`your-store` in `https://your-store.myshopline.com`) and
|
|
44
|
+
is required — it is what the authorize and token endpoints are addressed against. It is
|
|
45
|
+
read once, when the strategy is built, so serving several stores means one `provider`
|
|
46
|
+
entry per store.
|
|
47
|
+
|
|
48
|
+
The two positional arguments are SHOPLINE's app key and app secret. They may also be
|
|
49
|
+
passed by name, which takes precedence:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
provider :shopline, handle: 'your-store', app_key: 'app_key', app_secret: 'app_secret'
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Auth hash
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
auth = request.env['omniauth.auth']
|
|
59
|
+
|
|
60
|
+
auth['provider'] # => "shopline"
|
|
61
|
+
auth['credentials']['token'] # => the access token, valid for ten hours
|
|
62
|
+
auth['credentials']['expires_at'] # => 1762800000 (seconds since the epoch)
|
|
63
|
+
auth['credentials']['expires'] # => true
|
|
64
|
+
auth['credentials']['scope'] # => "read_products,read_orders"
|
|
65
|
+
auth['extra']['handle'] # => "your-store"
|
|
66
|
+
auth['extra']['scope'] # => "read_products,read_orders"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
SHOPLINE's token response identifies a store, not a person, and the strategy does not set
|
|
70
|
+
`uid`. Key records on `extra['handle']`.
|
|
71
|
+
|
|
72
|
+
## Configuring
|
|
73
|
+
|
|
74
|
+
### The callback URL
|
|
75
|
+
|
|
76
|
+
The strategy overrides `callback_url` to drop the query string OmniAuth would otherwise
|
|
77
|
+
append, because SHOPLINE matches `redirectUri` against the app's registered callback URLs
|
|
78
|
+
exactly. Otherwise it is derived from the request, including the mount prefix — a strategy
|
|
79
|
+
mounted under Devise's `/users` produces
|
|
80
|
+
`https://app.example.com/users/auth/shopline/callback`.
|
|
81
|
+
|
|
82
|
+
Pass `redirect_uri` (or `callback_url`) to override it outright:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
redirect_uri: 'https://app.example.com/users/auth/shopline/callback'
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Callback verification
|
|
89
|
+
|
|
90
|
+
SHOPLINE's authorize endpoint has no `state` parameter, so the CSRF nonce travels in
|
|
91
|
+
`customField`, the documented pass-through, and is checked against the session when the
|
|
92
|
+
callback comes back. SHOPLINE also signs the callback query string; the strategy verifies
|
|
93
|
+
that `sign` the way [SHOPLINE's own SDK](https://github.com/shoplineos/shopline-sdk-go/blob/main/client/sign.go)
|
|
94
|
+
does. Both checks are on by default and can be turned off individually:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
verify_state: false, # skip the customField nonce check
|
|
98
|
+
verify_signature: false # skip the `sign` check
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`provider_ignores_state` is left at `true` on purpose: SHOPLINE never sends `state`, so
|
|
102
|
+
`OmniAuth::Strategies::OAuth2`'s own check can never pass. Use `verify_state` instead.
|
|
103
|
+
|
|
104
|
+
### The store host
|
|
105
|
+
|
|
106
|
+
`client_options[:site]` defaults to `https://{handle}.myshopline.com` with the placeholder
|
|
107
|
+
filled in from `handle`. Setting it explicitly wins, which is useful for pointing the
|
|
108
|
+
strategy at a proxy or a test double:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
client_options: { site: 'https://shopline.test' }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## How it differs from a plain OAuth 2 strategy
|
|
115
|
+
|
|
116
|
+
Three parts of SHOPLINE's flow are not standard OAuth 2, and the strategy handles each
|
|
117
|
+
itself rather than inheriting it:
|
|
118
|
+
|
|
119
|
+
1. The authorize endpoint is a hash-routed admin page
|
|
120
|
+
(`/admin/oauth-web/#/oauth/authorize`), so its query string sits *after* the fragment
|
|
121
|
+
and cannot be built by `OAuth2::Client#authorize_url`.
|
|
122
|
+
2. There is no `state` parameter (see above).
|
|
123
|
+
3. `POST /admin/oauth/token/create` authenticates with `appkey`, `timestamp` and `sign`
|
|
124
|
+
headers rather than client credentials, takes `{"code": "..."}` as its whole body, and
|
|
125
|
+
answers with the token nested under `data`. SHOPLINE signs a POST over the request body
|
|
126
|
+
concatenated with the millisecond timestamp — not the sorted-parameter scheme it uses
|
|
127
|
+
for GETs.
|
|
128
|
+
|
|
129
|
+
## Development
|
|
130
|
+
|
|
131
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then run
|
|
132
|
+
`bundle exec rake spec` to run the tests. You can also run `bin/console` for an
|
|
133
|
+
interactive prompt.
|
|
134
|
+
|
|
135
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
136
|
+
|
|
137
|
+
The gemspec allows a wide dependency range, so CI also runs the suite against the oldest
|
|
138
|
+
versions it permits. To reproduce that locally:
|
|
139
|
+
|
|
140
|
+
$ BUNDLE_GEMFILE=gemfiles/floor.gemfile bundle install
|
|
141
|
+
$ BUNDLE_GEMFILE=gemfiles/floor.gemfile bundle exec rake spec
|
|
142
|
+
|
|
143
|
+
Run `bundle exec bundle-audit check --update` to check the locked dependency tree against
|
|
144
|
+
the advisory database; CI runs the same check weekly.
|
|
145
|
+
|
|
146
|
+
## Releasing
|
|
147
|
+
|
|
148
|
+
Releases are cut from the [GitHub Releases UI](https://github.com/dropstream/omniauth-shopline/releases/new);
|
|
149
|
+
GitHub Actions builds and publishes the gem to [rubygems.org](https://rubygems.org) via
|
|
150
|
+
[RubyGems trusted publishing](https://guides.rubygems.org/trusted-publishing/), so no API
|
|
151
|
+
key is stored in the repo and the gemspec can keep `rubygems_mfa_required`.
|
|
152
|
+
|
|
153
|
+
1. Bump `VERSION` in `lib/omniauth-shopline/version.rb` and add a `CHANGELOG.md` section
|
|
154
|
+
for it, along with a link definition for the new heading at the bottom of that file.
|
|
155
|
+
Commit both on `master`.
|
|
156
|
+
2. Draft a new release with the target set to `master` and the tag set to the version
|
|
157
|
+
prefixed with `v` — `v0.1.0`, not `0.1.0`. The prefix is stripped before comparing
|
|
158
|
+
against `version.rb`; the gem version itself carries no `v`.
|
|
159
|
+
3. Click **Generate release notes** — the preferred method — then publish. The generated
|
|
160
|
+
notes list the merged pull requests; `CHANGELOG.md` carries the curated prose and
|
|
161
|
+
links back to each release.
|
|
162
|
+
|
|
163
|
+
Publishing creates the tag, which fires the `Release` workflow: it reruns the full test
|
|
164
|
+
matrix, refuses to continue if the tag and `version.rb` disagree, and pushes the gem.
|
|
165
|
+
Note that RubyGems versions are immutable — a bad release can be yanked, never replaced,
|
|
166
|
+
so the version bump has to be committed before the release is published.
|
|
167
|
+
|
|
168
|
+
### One-time RubyGems setup
|
|
169
|
+
|
|
170
|
+
This gem has never been pushed, so the first release needs a **pending** trusted
|
|
171
|
+
publisher, which both claims the name `omniauth-shopline` and makes you an owner of it
|
|
172
|
+
once the push succeeds. On rubygems.org, open
|
|
173
|
+
[pending trusted publishers](https://rubygems.org/profile/oidc/pending_trusted_publishers)
|
|
174
|
+
-> **Create**, with:
|
|
175
|
+
|
|
176
|
+
| Field | Value |
|
|
177
|
+
| --- | --- |
|
|
178
|
+
| RubyGem name | `omniauth-shopline` |
|
|
179
|
+
| Repository owner | `dropstream` |
|
|
180
|
+
| Repository name | `omniauth-shopline` |
|
|
181
|
+
| Workflow filename | `release.yml` |
|
|
182
|
+
| Environment | `release` |
|
|
183
|
+
|
|
184
|
+
The repo also needs a GitHub environment named `release` (Settings -> Environments ->
|
|
185
|
+
New environment), because the publishing job runs in it.
|
|
186
|
+
|
|
187
|
+
After the first successful push the pending publisher becomes an ordinary trusted
|
|
188
|
+
publisher, managed from the gem's own **Trusted publishers** page. It only needs
|
|
189
|
+
revisiting if the gem is renamed or the repository moves.
|
|
190
|
+
|
|
191
|
+
## Contributing
|
|
192
|
+
|
|
193
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
194
|
+
https://github.com/dropstream/omniauth-shopline.
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
|
|
198
|
+
Available as open source under the terms of the [MIT License](LICENSE.txt).
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
require "omniauth-oauth2"
|
|
9
|
+
|
|
10
|
+
module OmniAuth
|
|
11
|
+
module Strategies
|
|
12
|
+
# OmniAuth strategy for SHOPLINE's OAuth 2.0 app authorization flow.
|
|
13
|
+
#
|
|
14
|
+
# https://developer.shopline.com/docs/apps/api-instructions-for-use/app-authorization/
|
|
15
|
+
#
|
|
16
|
+
# SHOPLINE deviates from plain OAuth 2 in three ways that this strategy has to
|
|
17
|
+
# handle itself rather than inherit:
|
|
18
|
+
#
|
|
19
|
+
# 1. The authorize endpoint is a hash-routed admin page, so its query string sits
|
|
20
|
+
# *after* the `#` fragment and cannot be built by `OAuth2::Client#authorize_url`.
|
|
21
|
+
# 2. There is no `state` parameter. `customField` is the documented pass-through,
|
|
22
|
+
# so the CSRF nonce travels in it (see #verify_state).
|
|
23
|
+
# 3. The token endpoint authenticates with `appkey`/`timestamp`/`sign` headers
|
|
24
|
+
# instead of client credentials, and signs POSTs over the request body.
|
|
25
|
+
class Shopline < OmniAuth::Strategies::OAuth2
|
|
26
|
+
SITE_TEMPLATE = "https://%<handle>s.myshopline.com"
|
|
27
|
+
|
|
28
|
+
# The placeholder is replaced with the configured handle unless the consumer
|
|
29
|
+
# supplies a site of their own.
|
|
30
|
+
DEFAULT_SITE = "https://{handle}.myshopline.com"
|
|
31
|
+
|
|
32
|
+
STATE_SESSION_KEY = "omniauth.shopline.state"
|
|
33
|
+
|
|
34
|
+
option :name, "shopline"
|
|
35
|
+
|
|
36
|
+
option :client_options, {
|
|
37
|
+
site: DEFAULT_SITE,
|
|
38
|
+
authorize_url: "/admin/oauth-web/#/oauth/authorize",
|
|
39
|
+
token_url: "/admin/oauth/token/create"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# SHOPLINE calls these the app key and app secret. They are the OAuth2
|
|
43
|
+
# client_id/client_secret positional arguments, and may also be passed by name.
|
|
44
|
+
option :app_key, nil
|
|
45
|
+
option :app_secret, nil
|
|
46
|
+
|
|
47
|
+
# SHOPLINE never sends `state`, so OmniAuth::Strategies::OAuth2's own check can
|
|
48
|
+
# never pass. Leave this alone and use :verify_state / :verify_signature below.
|
|
49
|
+
option :provider_ignores_state, true
|
|
50
|
+
|
|
51
|
+
# Round-trips a CSRF nonce through SHOPLINE's `customField` parameter.
|
|
52
|
+
option :verify_state, true
|
|
53
|
+
|
|
54
|
+
# Verifies the `sign` SHOPLINE appends to the callback query string.
|
|
55
|
+
option :verify_signature, true
|
|
56
|
+
|
|
57
|
+
attr_reader :handle
|
|
58
|
+
|
|
59
|
+
def initialize(app, *args, &block)
|
|
60
|
+
super
|
|
61
|
+
|
|
62
|
+
@handle = options[:handle] || raise(ArgumentError, "handle is required")
|
|
63
|
+
|
|
64
|
+
site = options.client_options[:site]
|
|
65
|
+
return unless site.nil? || site.to_s.include?("{handle}")
|
|
66
|
+
|
|
67
|
+
options.client_options[:site] = format(SITE_TEMPLATE, handle: @handle)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def app_key
|
|
71
|
+
options.app_key || options.client_id
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def app_secret
|
|
75
|
+
options.app_secret || options.client_secret
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The authorize URL is built by hand: `/admin/oauth-web/#/oauth/authorize` is a
|
|
79
|
+
# client-side route, so the query string belongs after the fragment. Handing the
|
|
80
|
+
# path to `OAuth2::Client#authorize_url` would produce
|
|
81
|
+
# `/admin/oauth-web/?appKey=...#/oauth/authorize`, which the page never reads.
|
|
82
|
+
def authorize_url
|
|
83
|
+
client_options = options.client_options
|
|
84
|
+
query = URI.encode_www_form(authorize_params_for_shopline)
|
|
85
|
+
|
|
86
|
+
"#{client_options[:site]}#{client_options[:authorize_url]}?#{query}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def request_phase
|
|
90
|
+
redirect authorize_url
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def callback_phase
|
|
94
|
+
error = request.params["error"].to_s
|
|
95
|
+
unless error.empty?
|
|
96
|
+
return fail!(error.to_sym, CallbackError.new(error, request.params["error_description"]))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
return fail!(:csrf_detected, CallbackError.new(:csrf_detected, "CSRF detected")) unless valid_state?
|
|
100
|
+
|
|
101
|
+
unless valid_signature?
|
|
102
|
+
return fail!(:invalid_signature, CallbackError.new(:invalid_signature, "Signature verification failed"))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
super
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# POST /admin/oauth/token/create, authenticated by the appkey/timestamp/sign
|
|
109
|
+
# headers rather than by client credentials, and answered with the token nested
|
|
110
|
+
# under `data`.
|
|
111
|
+
def build_access_token
|
|
112
|
+
timestamp = current_timestamp
|
|
113
|
+
body = JSON.generate(code: request.params["code"])
|
|
114
|
+
|
|
115
|
+
response = client.request(
|
|
116
|
+
:post,
|
|
117
|
+
options.client_options[:token_url],
|
|
118
|
+
body: body,
|
|
119
|
+
headers: {
|
|
120
|
+
"Content-Type" => "application/json",
|
|
121
|
+
"appkey" => app_key.to_s,
|
|
122
|
+
"timestamp" => timestamp.to_s,
|
|
123
|
+
"sign" => signature_for_body(body, timestamp)
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
access_token_from(response)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# OmniAuth::Strategy#callback_url appends the callback request's own query
|
|
131
|
+
# string, which would send those params to SHOPLINE as part of redirect_uri and
|
|
132
|
+
# fail the registered-URL match. Note that omniauth 2's `callback_path` already
|
|
133
|
+
# carries SCRIPT_NAME, so mount prefixes must not be added again.
|
|
134
|
+
def callback_url
|
|
135
|
+
options[:callback_url] || options[:redirect_uri] || (full_host + callback_path)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# OmniAuth merges this with OmniAuth::Strategies::OAuth2's own credentials block
|
|
139
|
+
# (token, expires_at, expires) rather than replacing it.
|
|
140
|
+
credentials do
|
|
141
|
+
{"scope" => access_token.params["scope"]}.compact
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
extra do
|
|
145
|
+
{
|
|
146
|
+
"handle" => handle,
|
|
147
|
+
"scope" => access_token.params["scope"]
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def authorize_params_for_shopline
|
|
154
|
+
params = {
|
|
155
|
+
appKey: app_key,
|
|
156
|
+
responseType: "code",
|
|
157
|
+
scope: options.scope,
|
|
158
|
+
redirectUri: callback_url
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
params[:customField] = new_state if options.verify_state
|
|
162
|
+
params.compact
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# SHOPLINE has no `state` parameter, but `customField` is passed through to the
|
|
166
|
+
# callback untouched, so it carries the nonce.
|
|
167
|
+
def new_state
|
|
168
|
+
state = SecureRandom.hex(24)
|
|
169
|
+
session[STATE_SESSION_KEY] = state
|
|
170
|
+
state
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def valid_state?
|
|
174
|
+
return true unless options.verify_state
|
|
175
|
+
|
|
176
|
+
expected = session.delete(STATE_SESSION_KEY)
|
|
177
|
+
!expected.to_s.empty? && secure_compare(request.params["customField"], expected)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# SHOPLINE signs the callback query the same way it signs any GET: the params
|
|
181
|
+
# other than `sign`, sorted by key and joined as `k=v&k=v`.
|
|
182
|
+
# https://developer.shopline.com/docs/apps/api-instructions-for-use/generate-and-verify-signatures/
|
|
183
|
+
def valid_signature?
|
|
184
|
+
return true unless options.verify_signature
|
|
185
|
+
|
|
186
|
+
received = request.GET["sign"]
|
|
187
|
+
return false if received.to_s.empty?
|
|
188
|
+
|
|
189
|
+
secure_compare(received, signature_for_query(request.GET))
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def access_token_from(response)
|
|
193
|
+
parsed = response.parsed
|
|
194
|
+
data = parsed["data"] if parsed.is_a?(Hash)
|
|
195
|
+
token = data["accessToken"] if data.is_a?(Hash)
|
|
196
|
+
|
|
197
|
+
raise ::OAuth2::Error, response if token.to_s.empty?
|
|
198
|
+
|
|
199
|
+
::OAuth2::AccessToken.new(
|
|
200
|
+
client,
|
|
201
|
+
token,
|
|
202
|
+
"expires_at" => data["expireTime"],
|
|
203
|
+
"scope" => data["scope"]
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Milliseconds since the epoch, as SHOPLINE's `timestamp` header requires.
|
|
208
|
+
def current_timestamp
|
|
209
|
+
(Time.now.to_f * 1000).to_i
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# SHOPLINE signs a POST over the request body concatenated with the timestamp --
|
|
213
|
+
# not the sorted-parameter scheme used for GETs. The body passed here must be the
|
|
214
|
+
# exact string that is sent.
|
|
215
|
+
# https://github.com/shoplineos/shopline-sdk-go/blob/main/client/sign.go
|
|
216
|
+
def signature_for_body(body, timestamp)
|
|
217
|
+
hmac_sha256("#{body}#{timestamp}")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def signature_for_query(params)
|
|
221
|
+
source = params.reject { |key, _| key.to_s == "sign" }
|
|
222
|
+
.sort_by { |key, _| key.to_s }
|
|
223
|
+
.map { |key, value| "#{key}=#{value}" }
|
|
224
|
+
.join("&")
|
|
225
|
+
|
|
226
|
+
hmac_sha256(source)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def hmac_sha256(source)
|
|
230
|
+
secret = app_secret.to_s
|
|
231
|
+
raise ArgumentError, "app_secret is required to sign SHOPLINE requests" if secret.empty?
|
|
232
|
+
|
|
233
|
+
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, source)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def secure_compare(received, expected)
|
|
237
|
+
received = received.to_s
|
|
238
|
+
expected = expected.to_s
|
|
239
|
+
return false unless received.bytesize == expected.bytesize
|
|
240
|
+
|
|
241
|
+
OpenSSL.fixed_length_secure_compare(received, expected)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
lib = File.expand_path("lib", __dir__)
|
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
5
|
+
require "omniauth-shopline/version"
|
|
6
|
+
|
|
7
|
+
Gem::Specification.new do |spec|
|
|
8
|
+
spec.name = "omniauth-shopline"
|
|
9
|
+
spec.version = Omniauth::Shopline::VERSION
|
|
10
|
+
spec.authors = ["Dropstream"]
|
|
11
|
+
spec.email = ["karl.falconer@getdropstream.com"]
|
|
12
|
+
|
|
13
|
+
spec.summary = "OmniAuth strategy for SHOPLINE"
|
|
14
|
+
spec.description = "In this gem you will find an OmniAuth SHOPLINE strategy"
|
|
15
|
+
spec.homepage = "https://github.com/dropstream/omniauth-shopline"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
|
|
18
|
+
spec.required_ruby_version = ">= 3.2"
|
|
19
|
+
|
|
20
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
21
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
22
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md"
|
|
23
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
24
|
+
|
|
25
|
+
# Ship only the library itself; development scaffolding stays out of the gem.
|
|
26
|
+
spec.files = Dir.chdir(__dir__) do
|
|
27
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
28
|
+
f.match(%r{\A(?:spec|bin|pkg|gemfiles|\.github)/}) ||
|
|
29
|
+
f.match(%r{\A(?:\.gitignore|\.rspec|\.ruby-version|\.ruby-gemset|Gemfile|Gemfile\.lock|Rakefile)\z})
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
spec.require_paths = ["lib"]
|
|
33
|
+
|
|
34
|
+
# Deliberately loose. This is a plugin: a ceiling here becomes a ceiling in every
|
|
35
|
+
# consuming application, so it allows the next major of both gems rather than
|
|
36
|
+
# blocking upgrades on the day one ships. The floors are where OmniAuth 2 support
|
|
37
|
+
# actually begins -- omniauth-oauth2 1.8.0 is its first release to require
|
|
38
|
+
# `omniauth ~> 2.0` -- and CI runs the suite against those floors as well as against
|
|
39
|
+
# the newest resolvable versions.
|
|
40
|
+
spec.add_dependency "omniauth", ">= 2.0", "< 4"
|
|
41
|
+
spec.add_dependency "omniauth-oauth2", ">= 1.8", "< 3"
|
|
42
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: omniauth-shopline
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Dropstream
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: omniauth
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '4'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '2.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '4'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: omniauth-oauth2
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '1.8'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '3'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '1.8'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '3'
|
|
52
|
+
description: In this gem you will find an OmniAuth SHOPLINE strategy
|
|
53
|
+
email:
|
|
54
|
+
- karl.falconer@getdropstream.com
|
|
55
|
+
executables: []
|
|
56
|
+
extensions: []
|
|
57
|
+
extra_rdoc_files: []
|
|
58
|
+
files:
|
|
59
|
+
- CHANGELOG.md
|
|
60
|
+
- LICENSE.txt
|
|
61
|
+
- README.md
|
|
62
|
+
- lib/omniauth-shopline.rb
|
|
63
|
+
- lib/omniauth-shopline/version.rb
|
|
64
|
+
- lib/omniauth/strategies/shopline.rb
|
|
65
|
+
- omniauth-shopline.gemspec
|
|
66
|
+
homepage: https://github.com/dropstream/omniauth-shopline
|
|
67
|
+
licenses:
|
|
68
|
+
- MIT
|
|
69
|
+
metadata:
|
|
70
|
+
allowed_push_host: https://rubygems.org
|
|
71
|
+
homepage_uri: https://github.com/dropstream/omniauth-shopline
|
|
72
|
+
changelog_uri: https://github.com/dropstream/omniauth-shopline/blob/master/CHANGELOG.md
|
|
73
|
+
rubygems_mfa_required: 'true'
|
|
74
|
+
rdoc_options: []
|
|
75
|
+
require_paths:
|
|
76
|
+
- lib
|
|
77
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '3.2'
|
|
82
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
83
|
+
requirements:
|
|
84
|
+
- - ">="
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: '0'
|
|
87
|
+
requirements: []
|
|
88
|
+
rubygems_version: 3.6.9
|
|
89
|
+
specification_version: 4
|
|
90
|
+
summary: OmniAuth strategy for SHOPLINE
|
|
91
|
+
test_files: []
|