diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..f8a8006 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,31 @@ +name: Audit + +# Kept out of ci.yml so a newly published advisory does not block a release that +# is otherwise green. +on: + push: + branches: [master] + pull_request: + branches: [master] + schedule: + # Mondays at 13:00 UTC — catches advisories published against the locked + # oauth2/rack chain without waiting for the next commit. + - cron: "0 13 * * 1" + workflow_dispatch: + +jobs: + audit: + name: Dependency audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Check for vulnerable dependencies + run: | + bundle exec bundle-audit update + bundle exec bundle-audit check diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..305fa18 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + # Lets the release workflow gate publishing on this same matrix. + workflow_call: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: ["3.2", "3.3", "3.4", "4.0"] + + name: Ruby ${{ matrix.ruby }} + steps: + - uses: actions/checkout@v5 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Run the test suite + run: bundle exec rake spec + + - name: Build the gem + run: bundle exec rake build + + # The gemspec deliberately allows a wide dependency range, so the floor gets the + # same suite rather than being taken on trust. + floor: + name: Oldest supported dependencies + runs-on: ubuntu-latest + env: + BUNDLE_GEMFILE: gemfiles/floor.gemfile + + steps: + - uses: actions/checkout@v5 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + + - name: Install the oldest allowed dependencies + run: bundle install --jobs 4 + + - name: Show what resolved + run: bundle list + + - name: Run the test suite + run: bundle exec rake spec diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3b65de4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release + +# Cut a release from the GitHub Releases UI: draft a release, set the tag to `vX.Y.Z` +# (matching lib/omniauth-shopline/version.rb), and publish it. GitHub creates the tag, +# which fires this workflow. +on: + release: + types: [published] + +jobs: + # Never publish a version that has not passed the matrix. + test: + uses: ./.github/workflows/ci.yml + + push: + name: Push gem to RubyGems.org + needs: test + runs-on: ubuntu-latest + + # Publishing uses RubyGems trusted publishing (OIDC), so there is no API key + # secret to rotate and it works with the gemspec's `rubygems_mfa_required`. + # + # omniauth-shopline has never been pushed, so the first release needs a *pending* + # trusted publisher instead of a per-gem one — it both claims the name and makes + # the pusher an owner: + # https://rubygems.org/profile/oidc/pending_trusted_publishers -> "Create", with + # gem name `omniauth-shopline`, owner `dropstream`, repository `omniauth-shopline`, + # workflow `release.yml`, and environment `release`. + # After the first successful push it becomes an ordinary trusted publisher, managed + # from the gem's own "Trusted publishers" page. + environment: release + + permissions: + id-token: write # mandatory for trusted publishing + # GitHub already created the release and its tag, so nothing here writes to + # the repo: `rake release` sees the tag and skips its own tag-and-push step. + contents: read + + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.release.tag_name }} + # rubygems/release-gem installs its own short-lived git credentials. + persist-credentials: false + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + # `rake release` builds whatever version.rb says, regardless of the tag. If the + # two disagree it would publish one version under another version's release, so + # stop here first. + - name: Verify the release tag matches the gem version + env: + TAG_NAME: ${{ github.event.release.tag_name }} + run: | + gem_version="$(ruby -r./lib/omniauth-shopline/version -e 'print Omniauth::Shopline::VERSION')" + tag_version="${TAG_NAME#v}" + if [ "$gem_version" != "$tag_version" ]; then + echo "::error::Release tag ${TAG_NAME} does not match lib/omniauth-shopline/version.rb (${gem_version})." + exit 1 + fi + echo "Releasing omniauth-shopline ${gem_version}" + + # Builds the gem and runs `gem push` against the gemspec's allowed_push_host. + - name: Push to RubyGems.org + uses: rubygems/release-gem@v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..edb13df --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status + +*.gem +gemfiles/*.lock + +# macOS +.DS_Store diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..34c5164 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..7921bd0 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.4.8 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..949f351 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,78 @@ +# Changelog + +All notable changes to this project are documented in this file. Each version heading +links to its [GitHub release](https://github.com/dropstream/omniauth-shopline/releases), +where the notes are generated from the merged pull requests. + +## [0.1.0] - 2026-08-27 + +First release published to RubyGems.org. Everything below is relative to the state of +`master` before it, which is how the gem was installed until now (`git:` in a Gemfile). + +### Changed + +- Runtime dependencies are declared as `omniauth >= 2.0, < 4` and + `omniauth-oauth2 >= 1.8, < 3`. A plugin's ceiling becomes a ceiling in every + application that installs it, so this allows the next major of both gems rather than + blocking consumers on the day one ships. The floors are where OmniAuth 2 support + actually begins — `omniauth-oauth2` 1.8.0 is its first release to require + `omniauth ~> 2.0` — and CI runs the suite against those floors as well as against the + newest resolvable versions. +- Requires Ruby >= 3.2, tested on 3.2 through 4.0. +- The app key and secret are read from the `client_id`/`client_secret` positional + arguments, with `app_key`/`app_secret` accepted as named options. Previously the + strategy read `options.app_key`, which the positional arguments never set, so every + request went out with an empty `appKey`. +- `client_options[:site]` is only derived from `handle` when it still holds the + `{handle}` placeholder, so an explicitly configured site now survives. +- Removed the unused `token_params` default (`grant_type: authorization_code`). The + token endpoint takes only `{"code": ...}`, so the option never reached SHOPLINE. +- Development toolchain: Bundler 4.x, `rake ~> 13.0`, `rspec ~> 3.13`, `webmock ~> 3.25`. +- Added GitHub Actions for CI, a weekly `bundle-audit` run, and releases published to + RubyGems.org from the GitHub Releases UI via trusted publishing (OIDC, no stored API + key). + +### Added + +- `LICENSE.txt` (MIT) is declared in the gemspec, along with `required_ruby_version` and + the usual `homepage`/`changelog`/`rubygems_mfa_required` metadata. +- CSRF protection for the callback. SHOPLINE has no `state` parameter, so the nonce + travels in `customField` — the documented pass-through — and is verified against the + session. Disable with `verify_state: false`. +- Verification of the `sign` SHOPLINE appends to the callback query, computed the way + [SHOPLINE's own SDK](https://github.com/shoplineos/shopline-sdk-go/blob/main/client/sign.go) + computes it. Disable with `verify_signature: false`. +- `scope` in the credentials hash, which the README documented but the code did not + provide. +- `handle` is exposed as a reader on the strategy. +- Integration specs covering the request phase, the token exchange and its signature, + state and signature verification, the auth hash, and the `callback_url` overrides. + +### Fixed + +- The authorize URL put its query string in front of the `#` fragment + (`/admin/oauth-web/?appKey=...#/oauth/authorize`), because `OAuth2::Client#authorize_url` + cannot build a URL for a hash-routed page. SHOPLINE's admin page never read those + params. It is now built by hand as + `/admin/oauth-web/#/oauth/authorize?appKey=...`. +- The token request was signed with the sorted-parameter scheme SHOPLINE uses for GETs + (`appkey=..×tamp=..`). A POST is signed over the request body concatenated with + the millisecond timestamp, and the signed string is now the exact body that is sent. +- Every callback failed with `csrf_detected`: the request phase never wrote + `omniauth.state`, and SHOPLINE does not echo a `state` parameter, so + `OmniAuth::Strategies::OAuth2#callback_phase` rejected the callback before the token + exchange. +- `callback_url` no longer doubles a mount prefix. The override added `script_name` to + `callback_path`, but OmniAuth 2 moved `script_name` into `callback_path` itself, so a + strategy mounted under a prefix (for example Devise's `/users`) would have sent + `redirectUri=http://host/users/users/auth/shopline/callback`. +- A token response without an access token used to `fail!` from inside + `build_access_token` and hand the resulting Rack triple back as the access token. It + now raises `OAuth2::Error`, which OmniAuth turns into an `invalid_credentials` failure. +- The callback nonce and signature are compared in constant time. +- The token's `scope` reaches `OAuth2::AccessToken` under a string key. It was passed + with a symbol key, which `access_token.params['scope']` never found, so + `extra['scope']` was always `nil`. +- Development scripts in `bin/` and the spec suite are no longer packaged into the gem. + +[0.1.0]: https://github.com/dropstream/omniauth-shopline/releases/tag/v0.1.0 diff --git a/Gemfile b/Gemfile index a4a216b..3050eed 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,14 @@ -source 'https://rubygems.org' +# frozen_string_literal: true + +source "https://rubygems.org" # Specify your gem's dependencies in omniauth-shopline.gemspec -gemspec \ No newline at end of file +gemspec + +group :development, :test do + gem "bundler-audit", "~> 0.9" + gem "rack-test", "~> 2.2" + gem "rake", "~> 13.0" + gem "rspec", "~> 3.13" + gem "webmock", "~> 3.25" +end diff --git a/Gemfile.lock b/Gemfile.lock index 76cca7d..2a86003 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,81 +2,146 @@ PATH remote: . specs: omniauth-shopline (0.1.0) - omniauth-oauth2 (~> 1.6) + omniauth (>= 2.0, < 4) + omniauth-oauth2 (>= 1.8, < 3) GEM remote: https://rubygems.org/ specs: + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + anonymous_loader (0.1.3) + version_gem (~> 1.1, >= 1.1.14) + auth-sanitizer (0.2.3) + version_gem (~> 1.1, >= 1.1.14) base64 (0.3.0) - bigdecimal (3.2.2) + bigdecimal (4.1.2) + bundler-audit (0.9.3) + bundler (>= 1.2.0) + thor (~> 1.0) + crack (1.0.1) + bigdecimal + rexml diff-lcs (1.6.2) - faraday (2.13.2) + faraday (2.14.3) faraday-net_http (>= 2.0, < 3.5) json logger - faraday-net_http (3.4.1) - net-http (>= 0.5.0) - hashie (5.0.0) - json (2.12.2) - jwt (3.1.2) + faraday-net_http (3.4.4) + net-http (~> 0.5) + hashdiff (1.2.1) + hashie (5.1.0) + logger + json (2.21.2) + jwt (3.2.0) base64 logger (1.7.0) - multi_xml (0.7.2) - bigdecimal (~> 3.1) - net-http (0.6.0) - uri - oauth2 (2.0.12) + multi_xml (0.9.1) + bigdecimal (>= 3.1, < 5) + net-http (0.9.1) + uri (>= 0.11.1) + oauth2 (2.0.25) + anonymous_loader (~> 0.1, >= 0.1.3) + auth-sanitizer (~> 0.2, >= 0.2.3) faraday (>= 0.17.3, < 4.0) jwt (>= 1.0, < 4.0) logger (~> 1.2) multi_xml (~> 0.5) rack (>= 1.2, < 4) - snaky_hash (~> 2.0, >= 2.0.3) - version_gem (>= 1.1.8, < 3) - omniauth (2.1.3) + snaky_hash (~> 2.0, >= 2.0.7) + version_gem (~> 1.1, >= 1.1.14) + omniauth (2.1.4) hashie (>= 3.4.6) + logger rack (>= 2.2.3) rack-protection - omniauth-oauth2 (1.8.0) - oauth2 (>= 1.4, < 3) + omniauth-oauth2 (1.9.0) + oauth2 (>= 2.0.2, < 3) omniauth (~> 2.0) - rack (3.1.16) - rack-protection (4.1.1) + public_suffix (7.0.5) + rack (3.2.7) + rack-protection (4.2.1) base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) rack-test (2.2.0) rack (>= 1.3) - rake (13.3.0) - rspec (3.13.1) + rake (13.4.2) + rexml (3.4.4) + rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.5) + rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.5) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-support (3.13.4) - snaky_hash (2.0.3) + rspec-support (3.13.7) + snaky_hash (2.0.7) hashie (>= 0.1.0, < 6) - version_gem (>= 1.1.8, < 3) - uri (1.0.3) - version_gem (1.1.8) + version_gem (~> 1.1, >= 1.1.14) + thor (1.5.0) + uri (1.1.1) + version_gem (1.1.15) + webmock (3.26.3) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) PLATFORMS - arm64-darwin-24 ruby DEPENDENCIES - bundler + bundler-audit (~> 0.9) omniauth-shopline! - rack-test - rake - rspec + rack-test (~> 2.2) + rake (~> 13.0) + rspec (~> 3.13) + webmock (~> 3.25) + +CHECKSUMS + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + anonymous_loader (0.1.3) sha256=084a18e2439144d955447dc11dfc982f41fcd1583ad32d4d55151325dc44cb55 + auth-sanitizer (0.2.3) sha256=db10aac92cfbe4c64ab637eebcbe1d67395d1694798041362173370f59933e3c + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bundler (4.0.11) sha256=5bcec0fb78302e48d02ee46f10ee6e6942be647ba5b44a6d1ddfda9a240ce785 + bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 + crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + faraday (2.14.3) sha256=1882247e6766615c8220b4392bf1d27f6ebb63d8e28267587cef1fb0bf37f278 + faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588 + hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 + hashie (5.1.0) sha256=c266471896f323c446ea8207f8ffac985d2718df0a0ba98651a3057096ca3870 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + multi_xml (0.9.1) sha256=7ce766b59c17241ed62976caeae1fae9b2431b263398c35396239a68c4a64e57 + net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 + oauth2 (2.0.25) sha256=2f736a2f93c2caa67c1b08dc3c9889bb907d62643f3183fefaa1723cba6a82ac + omniauth (2.1.4) sha256=42a05b0496f0d22e1dd85d42aaf602f064e36bb47a6826a27ab55e5ba608763c + omniauth-oauth2 (1.9.0) sha256=ed15f6d9d20991807ce114cc5b9c1453bce3645b64e51c68c90cff5ff153fee8 + omniauth-shopline (0.1.0) + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 + rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + snaky_hash (2.0.7) sha256=7d02c70012a3f932e48860cd024577908300c9aa615e0cb9b450aaa749cbcb4d + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + version_gem (1.1.15) sha256=a73241587b29252e3567e0c818ded80730eb754d43b508f6ce4db22ca9ba27d0 + webmock (3.26.3) sha256=77c612816bdcb2fc43db0e42fd98b19be7d4770fd708816fdde65a006664ddec BUNDLED WITH - 2.6.7 + 4.0.11 diff --git a/README.md b/README.md index 9ddb680..b05e0d9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ -# OmniAuth Shopline +# OmniAuth SHOPLINE -This is an OmniAuth strategy for authenticating with Shopline using OAuth2. +An OmniAuth strategy for authenticating with [SHOPLINE](https://www.shopline.com) using +its [OAuth 2.0 app authorization flow](https://developer.shopline.com/docs/apps/api-instructions-for-use/app-authorization/). + +Requires Ruby >= 3.2 and OmniAuth 2.x. ## Installation @@ -20,50 +23,176 @@ Or install it yourself as: ## Usage -Add the strategy to your `Gemfile` alongside OmniAuth: +OmniAuth 2 only accepts `POST` for the request phase. In a Rails app, add +[`omniauth-rails_csrf_protection`](https://github.com/cookpad/omniauth-rails_csrf_protection) +to your Gemfile and link to the provider with `button_to` or `link_to ..., method: :post`: ```ruby -gem 'omniauth' -gem 'omniauth-shopline' +gem 'omniauth-rails_csrf_protection' ``` -Then integrate the strategy into your middleware: - ```ruby -use OmniAuth::Builder do - provider :shopline, 'your_app_key', 'your_app_secret', { - handle: 'your_shopline_handle', - scope: 'read_products,read_orders' - } +Rails.application.config.middleware.use OmniAuth::Builder do + provider :shopline, + 'app_key', 'app_secret', + handle: 'your-store', + scope: 'read_products,read_orders' end ``` -## Configuration +`handle` is the store subdomain (`your-store` in `https://your-store.myshopline.com`) and +is required — it is what the authorize and token endpoints are addressed against. It is +read once, when the strategy is built, so serving several stores means one `provider` +entry per store. + +The two positional arguments are SHOPLINE's app key and app secret. They may also be +passed by name, which takes precedence: + +```ruby + provider :shopline, handle: 'your-store', app_key: 'app_key', app_secret: 'app_secret' +``` + +## Auth hash + +```ruby +auth = request.env['omniauth.auth'] + +auth['provider'] # => "shopline" +auth['credentials']['token'] # => the access token, valid for ten hours +auth['credentials']['expires_at'] # => 1762800000 (seconds since the epoch) +auth['credentials']['expires'] # => true +auth['credentials']['scope'] # => "read_products,read_orders" +auth['extra']['handle'] # => "your-store" +auth['extra']['scope'] # => "read_products,read_orders" +``` + +SHOPLINE's token response identifies a store, not a person, and the strategy does not set +`uid`. Key records on `extra['handle']`. + +## Configuring + +### The callback URL + +The strategy overrides `callback_url` to drop the query string OmniAuth would otherwise +append, because SHOPLINE matches `redirectUri` against the app's registered callback URLs +exactly. Otherwise it is derived from the request, including the mount prefix — a strategy +mounted under Devise's `/users` produces +`https://app.example.com/users/auth/shopline/callback`. + +Pass `redirect_uri` (or `callback_url`) to override it outright: + +```ruby + redirect_uri: 'https://app.example.com/users/auth/shopline/callback' +``` + +### Callback verification + +SHOPLINE's authorize endpoint has no `state` parameter, so the CSRF nonce travels in +`customField`, the documented pass-through, and is checked against the session when the +callback comes back. SHOPLINE also signs the callback query string; the strategy verifies +that `sign` the way [SHOPLINE's own SDK](https://github.com/shoplineos/shopline-sdk-go/blob/main/client/sign.go) +does. Both checks are on by default and can be turned off individually: + +```ruby + verify_state: false, # skip the customField nonce check + verify_signature: false # skip the `sign` check +``` -- `handle`: Your Shopline store handle (required) -- `scope`: Comma-separated list of permissions (optional, defaults to 'read_products') +`provider_ignores_state` is left at `true` on purpose: SHOPLINE never sends `state`, so +`OmniAuth::Strategies::OAuth2`'s own check can never pass. Use `verify_state` instead. -## Auth Hash +### The store host -The auth hash will contain: +`client_options[:site]` defaults to `https://{handle}.myshopline.com` with the placeholder +filled in from `handle`. Setting it explicitly wins, which is useful for pointing the +strategy at a proxy or a test double: ```ruby -{ - provider: 'shopline', - uid: 'user_id', - info: {}, - credentials: { - token: 'access_token', - expires_at: '2023-11-10T16:37:48.178+00:00', - scope: 'read_products,read_orders' - }, - extra: { - handle: 'your_shopline_handle', - scope: 'read_products,read_orders' - } -} + client_options: { site: 'https://shopline.test' } ``` +## How it differs from a plain OAuth 2 strategy + +Three parts of SHOPLINE's flow are not standard OAuth 2, and the strategy handles each +itself rather than inheriting it: + +1. The authorize endpoint is a hash-routed admin page + (`/admin/oauth-web/#/oauth/authorize`), so its query string sits *after* the fragment + and cannot be built by `OAuth2::Client#authorize_url`. +2. There is no `state` parameter (see above). +3. `POST /admin/oauth/token/create` authenticates with `appkey`, `timestamp` and `sign` + headers rather than client credentials, takes `{"code": "..."}` as its whole body, and + answers with the token nested under `data`. SHOPLINE signs a POST over the request body + concatenated with the millisecond timestamp — not the sorted-parameter scheme it uses + for GETs. + +## Development + +After checking out the repo, run `bin/setup` to install dependencies. Then run +`bundle exec rake spec` to run the tests. You can also run `bin/console` for an +interactive prompt. + +To install this gem onto your local machine, run `bundle exec rake install`. + +The gemspec allows a wide dependency range, so CI also runs the suite against the oldest +versions it permits. To reproduce that locally: + + $ BUNDLE_GEMFILE=gemfiles/floor.gemfile bundle install + $ BUNDLE_GEMFILE=gemfiles/floor.gemfile bundle exec rake spec + +Run `bundle exec bundle-audit check --update` to check the locked dependency tree against +the advisory database; CI runs the same check weekly. + +## Releasing + +Releases are cut from the [GitHub Releases UI](https://github.com/dropstream/omniauth-shopline/releases/new); +GitHub Actions builds and publishes the gem to [rubygems.org](https://rubygems.org) via +[RubyGems trusted publishing](https://guides.rubygems.org/trusted-publishing/), so no API +key is stored in the repo and the gemspec can keep `rubygems_mfa_required`. + +1. Bump `VERSION` in `lib/omniauth-shopline/version.rb` and add a `CHANGELOG.md` section + for it, along with a link definition for the new heading at the bottom of that file. + Commit both on `master`. +2. Draft a new release with the target set to `master` and the tag set to the version + prefixed with `v` — `v0.1.0`, not `0.1.0`. The prefix is stripped before comparing + against `version.rb`; the gem version itself carries no `v`. +3. Click **Generate release notes** — the preferred method — then publish. The generated + notes list the merged pull requests; `CHANGELOG.md` carries the curated prose and + links back to each release. + +Publishing creates the tag, which fires the `Release` workflow: it reruns the full test +matrix, refuses to continue if the tag and `version.rb` disagree, and pushes the gem. +Note that RubyGems versions are immutable — a bad release can be yanked, never replaced, +so the version bump has to be committed before the release is published. + +### One-time RubyGems setup + +This gem has never been pushed, so the first release needs a **pending** trusted +publisher, which both claims the name `omniauth-shopline` and makes you an owner of it +once the push succeeds. On rubygems.org, open +[pending trusted publishers](https://rubygems.org/profile/oidc/pending_trusted_publishers) +-> **Create**, with: + +| Field | Value | +| --- | --- | +| RubyGem name | `omniauth-shopline` | +| Repository owner | `dropstream` | +| Repository name | `omniauth-shopline` | +| Workflow filename | `release.yml` | +| Environment | `release` | + +The repo also needs a GitHub environment named `release` (Settings -> Environments -> +New environment), because the publishing job runs in it. + +After the first successful push the pending publisher becomes an ordinary trusted +publisher, managed from the gem's own **Trusted publishers** page. It only needs +revisiting if the gem is renamed or the repository moves. + +## Contributing + +Bug reports and pull requests are welcome on GitHub at +https://github.com/dropstream/omniauth-shopline. + ## License -The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). +Available as open source under the terms of the [MIT License](LICENSE.txt). diff --git a/Rakefile b/Rakefile index b2fb8da..b6ae734 100644 --- a/Rakefile +++ b/Rakefile @@ -1,6 +1,8 @@ -require 'bundler/gem_tasks' -require 'rspec/core/rake_task' +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:spec) -task :default => :spec \ No newline at end of file +task default: :spec diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..057b4a6 --- /dev/null +++ b/bin/console @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "omniauth-shopline" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +require "irb" +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/gemfiles/floor.gemfile b/gemfiles/floor.gemfile new file mode 100644 index 0000000..7152242 --- /dev/null +++ b/gemfiles/floor.gemfile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Resolves the oldest runtime dependencies the gemspec allows, so the declared floor +# is exercised in CI rather than assumed. Run with: +# +# BUNDLE_GEMFILE=gemfiles/floor.gemfile bundle install && \ +# BUNDLE_GEMFILE=gemfiles/floor.gemfile bundle exec rake spec +eval_gemfile File.expand_path("../Gemfile", __dir__) + +gem "omniauth", "2.0.0" +gem "omniauth-oauth2", "1.8.0" diff --git a/lib/omniauth-shopline.rb b/lib/omniauth-shopline.rb index 709bfd5..34ed257 100644 --- a/lib/omniauth-shopline.rb +++ b/lib/omniauth-shopline.rb @@ -1,2 +1,4 @@ -require 'omniauth-shopline/version' -require 'omniauth/strategies/shopline' \ No newline at end of file +# frozen_string_literal: true + +require "omniauth-shopline/version" +require "omniauth/strategies/shopline" diff --git a/lib/omniauth-shopline/version.rb b/lib/omniauth-shopline/version.rb index 766c372..8f7a251 100644 --- a/lib/omniauth-shopline/version.rb +++ b/lib/omniauth-shopline/version.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + module Omniauth module Shopline - VERSION = '0.1.0' + VERSION = "0.1.0" end -end \ No newline at end of file +end diff --git a/lib/omniauth/strategies/shopline.rb b/lib/omniauth/strategies/shopline.rb index 50f419f..9251c9f 100644 --- a/lib/omniauth/strategies/shopline.rb +++ b/lib/omniauth/strategies/shopline.rb @@ -1,124 +1,244 @@ -require 'omniauth-oauth2' -require 'digest' -require 'json' -require 'openssl' +# frozen_string_literal: true + +require "json" +require "openssl" +require "securerandom" +require "uri" + +require "omniauth-oauth2" module OmniAuth module Strategies + # OmniAuth strategy for SHOPLINE's OAuth 2.0 app authorization flow. + # + # https://developer.shopline.com/docs/apps/api-instructions-for-use/app-authorization/ + # + # SHOPLINE deviates from plain OAuth 2 in three ways that this strategy has to + # handle itself rather than inherit: + # + # 1. The authorize endpoint is a hash-routed admin page, so its query string sits + # *after* the `#` fragment and cannot be built by `OAuth2::Client#authorize_url`. + # 2. There is no `state` parameter. `customField` is the documented pass-through, + # so the CSRF nonce travels in it (see #verify_state). + # 3. The token endpoint authenticates with `appkey`/`timestamp`/`sign` headers + # instead of client credentials, and signs POSTs over the request body. class Shopline < OmniAuth::Strategies::OAuth2 - option :name, 'shopline' + SITE_TEMPLATE = "https://%s.myshopline.com" + + # The placeholder is replaced with the configured handle unless the consumer + # supplies a site of their own. + DEFAULT_SITE = "https://{handle}.myshopline.com" + + STATE_SESSION_KEY = "omniauth.shopline.state" + + option :name, "shopline" option :client_options, { - site: 'https://{handle}.myshopline.com', - authorize_url: '/admin/oauth-web/#/oauth/authorize', - token_url: '/admin/oauth/token/create' + site: DEFAULT_SITE, + authorize_url: "/admin/oauth-web/#/oauth/authorize", + token_url: "/admin/oauth/token/create" } - option :token_params, { grant_type: 'authorization_code' } + # SHOPLINE calls these the app key and app secret. They are the OAuth2 + # client_id/client_secret positional arguments, and may also be passed by name. + option :app_key, nil + option :app_secret, nil + + # SHOPLINE never sends `state`, so OmniAuth::Strategies::OAuth2's own check can + # never pass. Leave this alone and use :verify_state / :verify_signature below. + option :provider_ignores_state, true + + # Round-trips a CSRF nonce through SHOPLINE's `customField` parameter. + option :verify_state, true + + # Verifies the `sign` SHOPLINE appends to the callback query string. + option :verify_signature, true + + attr_reader :handle def initialize(app, *args, &block) super - @handle = options[:handle] || raise(ArgumentError, 'handle is required') - options.client_options.site = "https://#{@handle}.myshopline.com" + + @handle = options[:handle] || raise(ArgumentError, "handle is required") + + site = options.client_options[:site] + return unless site.nil? || site.to_s.include?("{handle}") + + options.client_options[:site] = format(SITE_TEMPLATE, handle: @handle) end - def request_phase - params = { - appKey: options.app_key, - responseType: 'code', - scope: options.scope, - redirectUri: callback_url - } + def app_key + options.app_key || options.client_id + end - redirect client.authorize_url(params) + def app_secret + options.app_secret || options.client_secret end - def callback_phase - error = request.params['error'] - error_description = request.params['error_description'] + # The authorize URL is built by hand: `/admin/oauth-web/#/oauth/authorize` is a + # client-side route, so the query string belongs after the fragment. Handing the + # path to `OAuth2::Client#authorize_url` would produce + # `/admin/oauth-web/?appKey=...#/oauth/authorize`, which the page never reads. + def authorize_url + client_options = options.client_options + query = URI.encode_www_form(authorize_params_for_shopline) - if error - fail!(error.to_sym, CallbackError.new(error, error_description)) - else - super - end + "#{client_options[:site]}#{client_options[:authorize_url]}?#{query}" end - def build_access_token - verifier = request.params['code'] - # time must be integer with milliseconds - timestamp = (Time.now.to_f * 1000).to_i - - # Generate signature for headers - header_params = { - appkey: options.app_key, - timestamp: timestamp - } - signature = generate_signature(header_params) + def request_phase + redirect authorize_url + end - headers = { - 'Content-Type' => 'application/json', - 'appkey' => options.app_key, - 'timestamp' => timestamp.to_s, - 'sign' => signature - } + def callback_phase + error = request.params["error"].to_s + unless error.empty? + return fail!(error.to_sym, CallbackError.new(error, request.params["error_description"])) + end + + return fail!(:csrf_detected, CallbackError.new(:csrf_detected, "CSRF detected")) unless valid_state? - # Body only contains the authorization code - body = { code: verifier } - - response = client.request(:post, options.client_options.token_url, { - body: body.to_json, - headers: headers - }) - - # Parse nested response structure: data.accessToken - if response.parsed['data'] && response.parsed['data']['accessToken'] - ::OAuth2::AccessToken.new( - client, - response.parsed['data']['accessToken'], - { - expires_at: response.parsed['data']['expireTime'], - scope: response.parsed['data']['scope'] - } - ) - else - fail!(:invalid_credentials, response.parsed) + unless valid_signature? + return fail!(:invalid_signature, CallbackError.new(:invalid_signature, "Signature verification failed")) end + + super end + # POST /admin/oauth/token/create, authenticated by the appkey/timestamp/sign + # headers rather than by client credentials, and answered with the token nested + # under `data`. + def build_access_token + timestamp = current_timestamp + body = JSON.generate(code: request.params["code"]) + + response = client.request( + :post, + options.client_options[:token_url], + body: body, + headers: { + "Content-Type" => "application/json", + "appkey" => app_key.to_s, + "timestamp" => timestamp.to_s, + "sign" => signature_for_body(body, timestamp) + } + ) + + access_token_from(response) + end + + # OmniAuth::Strategy#callback_url appends the callback request's own query + # string, which would send those params to SHOPLINE as part of redirect_uri and + # fail the registered-URL match. Note that omniauth 2's `callback_path` already + # carries SCRIPT_NAME, so mount prefixes must not be added again. def callback_url - options[:redirect_uri] || full_host + script_name + callback_path + options[:callback_url] || options[:redirect_uri] || (full_host + callback_path) + end + + # OmniAuth merges this with OmniAuth::Strategies::OAuth2's own credentials block + # (token, expires_at, expires) rather than replacing it. + credentials do + {"scope" => access_token.params["scope"]}.compact end extra do { - 'handle' => @handle, - 'scope' => access_token.params['scope'] + "handle" => handle, + "scope" => access_token.params["scope"] } end private - # https://developer.shopline.com/docs/apps/api-instructions-for-use/generate-and-verify-signatures/?lang=en - def generate_signature(params) - # Sort parameters by key alphabetically (excluding sign parameter) - sorted_params = params.reject { |k, _| k == :sign }.sort.to_h + def authorize_params_for_shopline + params = { + appKey: app_key, + responseType: "code", + scope: options.scope, + redirectUri: callback_url + } + + params[:customField] = new_state if options.verify_state + params.compact + end + + # SHOPLINE has no `state` parameter, but `customField` is passed through to the + # callback untouched, so it carries the nonce. + def new_state + state = SecureRandom.hex(24) + session[STATE_SESSION_KEY] = state + state + end + + def valid_state? + return true unless options.verify_state + + expected = session.delete(STATE_SESSION_KEY) + !expected.to_s.empty? && secure_compare(request.params["customField"], expected) + end + + # SHOPLINE signs the callback query the same way it signs any GET: the params + # other than `sign`, sorted by key and joined as `k=v&k=v`. + # https://developer.shopline.com/docs/apps/api-instructions-for-use/generate-and-verify-signatures/ + def valid_signature? + return true unless options.verify_signature + + received = request.GET["sign"] + return false if received.to_s.empty? + + secure_compare(received, signature_for_query(request.GET)) + end + + def access_token_from(response) + parsed = response.parsed + data = parsed["data"] if parsed.is_a?(Hash) + token = data["accessToken"] if data.is_a?(Hash) + + raise ::OAuth2::Error, response if token.to_s.empty? + + ::OAuth2::AccessToken.new( + client, + token, + "expires_at" => data["expireTime"], + "scope" => data["scope"] + ) + end + + # Milliseconds since the epoch, as SHOPLINE's `timestamp` header requires. + def current_timestamp + (Time.now.to_f * 1000).to_i + end + + # SHOPLINE signs a POST over the request body concatenated with the timestamp -- + # not the sorted-parameter scheme used for GETs. The body passed here must be the + # exact string that is sent. + # https://github.com/shoplineos/shopline-sdk-go/blob/main/client/sign.go + def signature_for_body(body, timestamp) + hmac_sha256("#{body}#{timestamp}") + end + + def signature_for_query(params) + source = params.reject { |key, _| key.to_s == "sign" } + .sort_by { |key, _| key.to_s } + .map { |key, value| "#{key}=#{value}" } + .join("&") + + hmac_sha256(source) + end - # Create source string from sorted parameters - source = sorted_params.map { |k, v| "#{k}=#{v}" }.join('&') + def hmac_sha256(source) + secret = app_secret.to_s + raise ArgumentError, "app_secret is required to sign SHOPLINE requests" if secret.empty? - # Generate HMAC-SHA256 signature using helper method - generate_hmac_sha256(source, options.app_secret) + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, source) end - def generate_hmac_sha256(source, secret) - raise ArgumentError, "Source and secret must not be empty." if source.to_s.empty? || secret.to_s.empty? + def secure_compare(received, expected) + received = received.to_s + expected = expected.to_s + return false unless received.bytesize == expected.bytesize - digest = OpenSSL::Digest.new('sha256') - OpenSSL::HMAC.hexdigest(digest, secret, source) - rescue StandardError => e - raise "Error generating HMAC-SHA256 signature: #{e}" - nil + OpenSSL.fixed_length_secure_compare(received, expected) end end end diff --git a/omniauth-shopline.gemspec b/omniauth-shopline.gemspec index 29ee8d6..835f2a7 100644 --- a/omniauth-shopline.gemspec +++ b/omniauth-shopline.gemspec @@ -1,24 +1,42 @@ -# -*- encoding: utf-8 -*- -require File.expand_path('../lib/omniauth-shopline/version', __FILE__) +# frozen_string_literal: true + +lib = File.expand_path("lib", __dir__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require "omniauth-shopline/version" Gem::Specification.new do |spec| spec.name = "omniauth-shopline" + spec.version = Omniauth::Shopline::VERSION spec.authors = ["Dropstream"] - spec.email = [""] - spec.description = %q{Omniauth OAuth2 strategy for Shopline} - spec.summary = %q{Omniauth OAuth2 strategy for Shopline} + spec.email = ["karl.falconer@getdropstream.com"] + + spec.summary = "OmniAuth strategy for SHOPLINE" + spec.description = "In this gem you will find an OmniAuth SHOPLINE strategy" spec.homepage = "https://github.com/dropstream/omniauth-shopline" spec.license = "MIT" - spec.files = `git ls-files`.split($\) - spec.executables = spec.files.grep(%r{^bin/}).map{ |f| File.basename(f) } - spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) + spec.required_ruby_version = ">= 3.2" + + spec.metadata["allowed_push_host"] = "https://rubygems.org" + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md" + spec.metadata["rubygems_mfa_required"] = "true" + + # Ship only the library itself; development scaffolding stays out of the gem. + spec.files = Dir.chdir(__dir__) do + `git ls-files -z`.split("\x0").reject do |f| + f.match(%r{\A(?:spec|bin|pkg|gemfiles|\.github)/}) || + f.match(%r{\A(?:\.gitignore|\.rspec|\.ruby-version|\.ruby-gemset|Gemfile|Gemfile\.lock|Rakefile)\z}) + end + end spec.require_paths = ["lib"] - spec.version = Omniauth::Shopline::VERSION - spec.add_runtime_dependency 'omniauth-oauth2', '~> 1.6' - spec.add_development_dependency "bundler" - spec.add_development_dependency "rake" - spec.add_development_dependency "rspec" - spec.add_development_dependency "rack-test" -end \ No newline at end of file + # Deliberately loose. This is a plugin: a ceiling here becomes a ceiling in every + # consuming application, so it allows the next major of both gems rather than + # blocking upgrades on the day one ships. The floors are where OmniAuth 2 support + # actually begins -- omniauth-oauth2 1.8.0 is its first release to require + # `omniauth ~> 2.0` -- and CI runs the suite against those floors as well as against + # the newest resolvable versions. + spec.add_dependency "omniauth", ">= 2.0", "< 4" + spec.add_dependency "omniauth-oauth2", ">= 1.8", "< 3" +end diff --git a/spec/omniauth/shopline_spec.rb b/spec/omniauth/shopline_spec.rb new file mode 100644 index 0000000..e196372 --- /dev/null +++ b/spec/omniauth/shopline_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.describe Omniauth::Shopline do + it "has a version number" do + expect(Omniauth::Shopline::VERSION).not_to be nil + end +end diff --git a/spec/omniauth/strategies/shopline_spec.rb b/spec/omniauth/strategies/shopline_spec.rb index 124ef2d..75e59a3 100644 --- a/spec/omniauth/strategies/shopline_spec.rb +++ b/spec/omniauth/strategies/shopline_spec.rb @@ -1,36 +1,422 @@ -require 'spec_helper' +# frozen_string_literal: true -describe OmniAuth::Strategies::Shopline do - let(:app) { lambda { |env| [200, {}, ["Hello."]] } } - let(:strategy) { OmniAuth::Strategies::Shopline.new(app, 'app_key', 'app_secret', handle: 'test-store') } +RSpec.describe OmniAuth::Strategies::Shopline do + let(:inner_app) { ->(_env) { [200, {}, ["Hello."]] } } + let(:strategy_options) { {handle: "test-store"} } + let(:strategy) { described_class.new(inner_app, "app_key", "app_secret", **strategy_options) } - describe '#client_options' do - it 'has correct site' do - expect(strategy.client.site).to eq('https://test-store.myshopline.com') + def app + described_class.new(inner_app, "app_key", "app_secret", **strategy_options) + end + + def hmac(source, secret = "app_secret") + OpenSSL::HMAC.hexdigest("sha256", secret, source) + end + + # SHOPLINE signs a GET (and the authorization callback) over its params, minus + # `sign`, sorted by key and joined as `k=v&k=v`. + def sign_query(params) + hmac(params.reject { |key, _| key.to_s == "sign" }.sort.map { |key, value| "#{key}=#{value}" }.join("&")) + end + + # The authorize URL's query sits after the `#` fragment, so URI#query cannot read it. + def query_after_fragment(url) + URI.decode_www_form(url.split("?", 2).last).to_h + end + + def failure_message + URI.decode_www_form(URI.parse(last_response.headers["Location"]).query).to_h["message"] + end + + describe "#client_options" do + subject(:client_options) { strategy.options.client_options } + + it "derives the site from the store handle" do + expect(client_options[:site]).to eq("https://test-store.myshopline.com") + end + + it "has the correct authorize url" do + expect(client_options[:authorize_url]).to eq("/admin/oauth-web/#/oauth/authorize") + end + + it "has the correct token url" do + expect(client_options[:token_url]).to eq("/admin/oauth/token/create") + end + + context "when the site is overridden" do + let(:strategy_options) { {handle: "test-store", client_options: {site: "https://proxy.example.com"}} } + + it "leaves the configured site alone" do + expect(client_options[:site]).to eq("https://proxy.example.com") + end + end + + it "does not leak one store's site into another strategy instance" do + other = described_class.new(inner_app, "app_key", "app_secret", handle: "other-store") + + expect([strategy.options.client_options[:site], other.options.client_options[:site]]) + .to eq(["https://test-store.myshopline.com", "https://other-store.myshopline.com"]) + end + end + + describe "initialization" do + it "raises when no handle is given" do + expect { described_class.new(inner_app, "app_key", "app_secret") } + .to raise_error(ArgumentError, "handle is required") + end + + it "exposes the handle" do + expect(strategy.handle).to eq("test-store") + end + + it "reads the app key and secret from the positional client credentials" do + expect([strategy.app_key, strategy.app_secret]).to eq(%w[app_key app_secret]) + end + + context "when the credentials are passed by SHOPLINE's own names" do + let(:strategy_options) { {handle: "test-store", app_key: "named_key", app_secret: "named_secret"} } + + it "prefers app_key/app_secret" do + expect([strategy.app_key, strategy.app_secret]).to eq(%w[named_key named_secret]) + end + end + end + + describe "the request phase" do + let(:strategy_options) { {handle: "test-store", scope: "read_products,read_orders"} } + let(:session) { {} } + + around do |example| + previous = OmniAuth.config.request_validation_phase + OmniAuth.config.request_validation_phase = proc {} + example.run + OmniAuth.config.request_validation_phase = previous + end + + def start_request(env = {}) + post "/auth/shopline", {}, {"rack.session" => session}.merge(env) + end + + it "redirects to the hash-routed authorize page with the query after the fragment" do + start_request + + expect(last_response.status).to eq(302) + expect(last_response.headers["Location"]) + .to start_with("https://test-store.myshopline.com/admin/oauth-web/#/oauth/authorize?") + end + + # OAuth2::Client#authorize_url would have produced + # `/admin/oauth-web/?appKey=...#/oauth/authorize`, which the admin page never reads. + it "does not push the query in front of the fragment" do + start_request + + expect(last_response.headers["Location"]).not_to include("/admin/oauth-web/?") + end + + it "passes the app key, response type, scope and redirect uri" do + start_request + + expect(query_after_fragment(last_response.headers["Location"])) + .to include( + "appKey" => "app_key", + "responseType" => "code", + "scope" => "read_products,read_orders", + "redirectUri" => "http://example.org/auth/shopline/callback" + ) + end + + # SHOPLINE has no `state` parameter; `customField` is the documented pass-through. + it "carries a CSRF nonce in customField and remembers it in the session" do + start_request + + nonce = query_after_fragment(last_response.headers["Location"])["customField"] + expect(nonce).to match(/\A[0-9a-f]{48}\z/) + expect(session["omniauth.shopline.state"]).to eq(nonce) + end + + context "when state verification is disabled" do + let(:strategy_options) { {handle: "test-store", verify_state: false} } + + it "omits customField" do + start_request + + expect(query_after_fragment(last_response.headers["Location"])).not_to have_key("customField") + end end - it 'has correct authorize_url' do - expect(strategy.client.options[:authorize_url]).to eq('/admin/oauth-web/#/oauth/authorize') + context "when no scope is configured" do + let(:strategy_options) { {handle: "test-store"} } + + it "omits the scope" do + start_request + + expect(query_after_fragment(last_response.headers["Location"])).not_to have_key("scope") + end end - it 'has correct token_url' do - expect(strategy.client.options[:token_url]).to eq('/admin/oauth/token/create') + # omniauth 2's `callback_path` already carries SCRIPT_NAME. + it "keeps a mount point in the redirect uri exactly once" do + start_request("SCRIPT_NAME" => "/users") + + expect(query_after_fragment(last_response.headers["Location"])["redirectUri"]) + .to eq("http://example.org/users/auth/shopline/callback") + end + + context "when a redirect_uri is configured" do + let(:strategy_options) { {handle: "test-store", redirect_uri: "https://app.example.com/shopline/callback"} } + + it "uses it verbatim" do + start_request + + expect(query_after_fragment(last_response.headers["Location"])["redirectUri"]) + .to eq("https://app.example.com/shopline/callback") + end end end - describe '#request_phase' do - it 'redirects to the authorize URL' do - allow(strategy).to receive(:callback_url).and_return('http://example.com/callback') - expect(strategy).to receive(:redirect).with(/appKey=app_key/) - strategy.request_phase + describe "the callback phase" do + let(:nonce) { "a" * 48 } + let(:session) { {"omniauth.shopline.state" => nonce} } + let(:token_url) { "https://test-store.myshopline.com/admin/oauth/token/create" } + let(:token_response) do + { + code: 200, + i18nCode: "SUCCESS", + message: nil, + data: { + accessToken: "token123", + expireTime: "2099-11-10T16:37:48.178+00:00", + scope: "read_products,read_orders" + }, + traceId: "trace" + } + end + + before do + stub_request(:post, token_url).to_return( + status: 200, + body: token_response.to_json, + headers: {"Content-Type" => "application/json"} + ) + end + + def callback_params(overrides = {}) + params = { + "appkey" => "app_key", + "code" => "auth_code", + "customField" => nonce, + "handle" => "test-store", + "timestamp" => "1755000000000" + }.merge(overrides) + + params.key?("sign") ? params : params.merge("sign" => sign_query(params)) + end + + def complete_callback(params = callback_params, env = {}) + get "/auth/shopline/callback?#{URI.encode_www_form(params)}", {}, {"rack.session" => session}.merge(env) + end + + def token_request + captured = nil + expect(WebMock).to have_requested(:post, token_url).with { |request| captured = request } + captured + end + + def token_request_header(name) + token_request.headers.find { |key, _| key.casecmp(name).zero? }&.last.to_s + end + + it "posts only the authorization code as the request body" do + complete_callback + + expect(token_request.body).to eq('{"code":"auth_code"}') + end + + it "authenticates the token request with the app key and a millisecond timestamp" do + complete_callback + + expect(token_request_header("appkey")).to eq("app_key") + expect(token_request_header("timestamp")).to match(/\A\d{13}\z/) + expect(token_request_header("content-type")).to eq("application/json") + end + + # https://github.com/shoplineos/shopline-sdk-go/blob/main/client/sign.go + it "signs the token request over the exact body concatenated with the timestamp" do + complete_callback + + expect(token_request_header("sign")) + .to eq(hmac("#{token_request.body}#{token_request_header("timestamp")}")) + end + + # The sorted-parameter scheme is for GETs; signing the POST that way had SHOPLINE + # rejecting every token exchange. + it "does not sign the token request with the sorted-parameter scheme" do + complete_callback + + expect(token_request_header("sign")) + .not_to eq(hmac("appkey=app_key×tamp=#{token_request_header("timestamp")}")) + end + + it "builds an auth hash from the nested token payload" do + complete_callback + + auth = last_request.env["omniauth.auth"] + expect(auth["provider"]).to eq("shopline") + expect(auth["credentials"]["token"]).to eq("token123") + expect(auth["credentials"]["expires_at"]).to eq(Time.parse("2099-11-10T16:37:48.178+00:00").to_i) + expect(auth["credentials"]["scope"]).to eq("read_products,read_orders") + expect(auth["extra"]).to eq("handle" => "test-store", "scope" => "read_products,read_orders") + end + + context "when the token payload carries no expireTime" do + let(:token_response) do + {code: 200, data: {accessToken: "token123", scope: "read_products,read_orders"}} + end + + it "reports a token that does not expire" do + complete_callback + + credentials = last_request.env["omniauth.auth"]["credentials"] + expect(credentials["token"]).to eq("token123") + expect(credentials["expires"]).to be(false) + end + end + + describe "state verification" do + it "fails when customField does not match the session nonce" do + complete_callback(callback_params("customField" => "b" * 48)) + + expect(failure_message).to eq("csrf_detected") + end + + it "fails when customField is missing" do + params = callback_params.reject { |key, _| key == "customField" } + + complete_callback(params.merge("sign" => sign_query(params))) + + expect(failure_message).to eq("csrf_detected") + end + + it "fails when the session has no nonce" do + session.clear + + complete_callback + + expect(failure_message).to eq("csrf_detected") + end + + it "consumes the nonce so a callback cannot be replayed" do + complete_callback + + expect(session).not_to have_key("omniauth.shopline.state") + end + + context "when state verification is disabled" do + let(:strategy_options) { {handle: "test-store", verify_state: false} } + + it "accepts a callback with no customField" do + params = callback_params.reject { |key, _| key == "customField" } + + complete_callback(params.merge("sign" => sign_query(params))) + + expect(last_request.env["omniauth.auth"]["credentials"]["token"]).to eq("token123") + end + end + end + + describe "signature verification" do + it "fails when the signature does not match the query" do + complete_callback(callback_params("sign" => hmac("tampered"))) + + expect(failure_message).to eq("invalid_signature") + end + + it "fails when the signature is missing" do + complete_callback(callback_params("sign" => "")) + + expect(failure_message).to eq("invalid_signature") + end + + it "fails when a signed parameter was tampered with" do + params = callback_params + complete_callback(params.merge("code" => "someone_elses_code")) + + expect(failure_message).to eq("invalid_signature") + end + + it "verifies signatures over parameters it does not otherwise use" do + complete_callback(callback_params("lang" => "en")) + + expect(last_request.env["omniauth.auth"]["credentials"]["token"]).to eq("token123") + end + + context "when signature verification is disabled" do + let(:strategy_options) { {handle: "test-store", verify_signature: false} } + + it "accepts an unsigned callback" do + params = callback_params.reject { |key, _| key == "sign" } + + complete_callback(params) + + expect(last_request.env["omniauth.auth"]["credentials"]["token"]).to eq("token123") + end + end + end + + describe "failures" do + it "passes a provider error through" do + params = callback_params("error" => "access_denied", "error_description" => "Merchant declined") + + complete_callback(params.merge("sign" => sign_query(params))) + + expect(failure_message).to eq("access_denied") + end + + it "fails when the token payload carries no access token" do + stub_request(:post, token_url).to_return( + status: 200, + body: {code: 40_000, i18nCode: "INVALID_CODE", message: "code expired"}.to_json, + headers: {"Content-Type" => "application/json"} + ) + + complete_callback + + expect(failure_message).to eq("invalid_credentials") + end + + it "fails when the token endpoint returns an error status" do + stub_request(:post, token_url).to_return(status: 400, body: "Bad Request") + + complete_callback + + expect(failure_message).to eq("invalid_credentials") + end end end - describe 'initialization' do - it 'raises an error if handle is not provided' do - expect { - OmniAuth::Strategies::Shopline.new(app, 'app_key', 'app_secret') - }.to raise_error(ArgumentError, 'handle is required') + describe "#callback_url" do + subject(:callback_url) { strategy.callback_url } + + before { strategy.instance_variable_set(:@env, Rack::MockRequest.env_for("http://app.example.com/auth/shopline/callback?code=abc")) } + + it "drops the callback request's own query string" do + expect(callback_url).to eq("http://app.example.com/auth/shopline/callback") + end + + context "when redirect_uri is configured" do + let(:strategy_options) { {handle: "test-store", redirect_uri: "https://app.example.com/shopline"} } + + it "uses the configured value" do + expect(callback_url).to eq("https://app.example.com/shopline") + end + end + + context "when callback_url is configured" do + let(:strategy_options) { {handle: "test-store", callback_url: "https://app.example.com/explicit"} } + + it "uses the configured value" do + expect(callback_url).to eq("https://app.example.com/explicit") + end end end -end \ No newline at end of file +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c5bfa72..211284d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,11 +1,22 @@ -require 'bundler/setup' -require 'rspec' -require 'rack/test' -require 'omniauth-shopline' +# frozen_string_literal: true + +require "bundler/setup" +require "rack/test" +require "webmock/rspec" +require "omniauth-shopline" + +OmniAuth.config.logger = Logger.new(File::NULL) RSpec.configure do |config| config.include Rack::Test::Methods + + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + config.expect_with :rspec do |c| c.syntax = :expect end -end \ No newline at end of file +end