From 64b89f7ef7f9977d14fcb607ecbb0f4f1d8fb1fa Mon Sep 17 00:00:00 2001 From: Karl Falconer Date: Thu, 27 Aug 2026 10:33:27 -0400 Subject: [PATCH] Relax dependencies, fix the SHOPLINE flow, and automate releases Prepares the first push to RubyGems.org. Runtime dependencies become `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 a CI job runs the suite against those floors so the range is tested rather than assumed. The integration specs added here found the flow could not complete at all, so this also fixes, against SHOPLINE's docs and its official Go SDK: - `appKey` was always empty. The strategy read `options.app_key`, which the `client_id`/`client_secret` positional arguments never set, so the app secret used for signing was nil too. - The authorize URL put its query in front of the `#` fragment, because `OAuth2::Client#authorize_url` cannot build a URL for a hash-routed page. SHOPLINE's admin page never read those params. - The token request was signed with the sorted-parameter scheme SHOPLINE uses for GETs. A POST is signed over the request body concatenated with the millisecond timestamp. - Every callback failed `csrf_detected`. SHOPLINE has no `state` parameter, so the nonce now travels in `customField`, the documented pass-through, and the `sign` on the callback query is verified the way SHOPLINE's SDK verifies it. Both checks are opt-out. - `callback_url` doubled a mount prefix, since omniauth 2 moved SCRIPT_NAME into `callback_path` itself. - An error payload called `fail!` from inside `build_access_token` and handed the resulting Rack triple back as the access token. Packaging and tooling follow omniauth-fdc and omniauth-shipbob: MIT license declared in the gemspec, development scaffolding kept out of the built gem, GitHub Actions for CI and a weekly bundle-audit, and releases published from the GitHub Releases UI via RubyGems trusted publishing. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/audit.yml | 31 ++ .github/workflows/ci.yml | 56 +++ .github/workflows/release.yml | 68 ++++ .gitignore | 17 + .rspec | 3 + .ruby-version | 1 + CHANGELOG.md | 78 ++++ Gemfile | 14 +- Gemfile.lock | 135 +++++-- README.md | 193 ++++++++-- Rakefile | 8 +- bin/console | 11 + bin/setup | 8 + gemfiles/floor.gemfile | 11 + lib/omniauth-shopline.rb | 6 +- lib/omniauth-shopline/version.rb | 6 +- lib/omniauth/strategies/shopline.rb | 284 +++++++++----- omniauth-shopline.gemspec | 48 ++- spec/omniauth/shopline_spec.rb | 7 + spec/omniauth/strategies/shopline_spec.rb | 430 ++++++++++++++++++++-- spec/spec_helper.rb | 21 +- 21 files changed, 1236 insertions(+), 200 deletions(-) create mode 100644 .github/workflows/audit.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 .ruby-version create mode 100644 CHANGELOG.md create mode 100755 bin/console create mode 100755 bin/setup create mode 100644 gemfiles/floor.gemfile create mode 100644 spec/omniauth/shopline_spec.rb 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