From cde45626e8af8d9645b4c344603cf6fbc4a68c80 Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Tue, 27 Oct 2026 06:42:29 -0700 Subject: [PATCH 1/2] Add Duel performance regression guards Duel runs the benchmarks in duel/ on the main branch and on a change side by side, and fails the Perf workflow when the change is slower. The 20 benchmarks cover building authorize URLs, the token endpoint (auth code, OIDC, client credentials, refresh, JWT assertion, revoke), signed API requests and redirects, AccessToken building and serializing, Response parsing (JSON, form, XML), errors, and the time to require the gem. Every request goes to Faraday's test adapter, so nothing waits on a socket. The rest comes from the kettle-jem template, with workflows.duel set in .structuredmerge/kettle-jem.yml: the Perf workflow, .duel.rb, gemfiles/modular/duel.gemfile (loaded by the root Gemfile and a new perf appraisal), the rake duel task, the RuboCop include for .duel.rb, the README badge, and a CHANGELOG entry. Each file matches what kettle-jem generates, so a template run keeps all of it, and nothing needs a freeze block or a per-file override. Duel requires Ruby 3.4 or later, so older Rubies install neither duel nor stackprof, and rake duel only warns there. --- .duel.rb | 25 +++++ .github/workflows/perf.yml | 186 ++++++++++++++++++++++++++++++++ .rubocop.yml | 5 + .structuredmerge/kettle-jem.yml | 11 ++ Appraisals | 7 ++ CHANGELOG.md | 5 + Gemfile | 3 + Gemfile.lock | 9 ++ README.md | 4 +- Rakefile | 14 +++ duel/access_token_duel.rb | 32 ++++++ duel/authorize_duel.rb | 39 +++++++ duel/boot_duel.rb | 4 + duel/duel_helper.rb | 101 +++++++++++++++++ duel/request_duel.rb | 30 ++++++ duel/response_duel.rb | 42 ++++++++ duel/token_duel.rb | 65 +++++++++++ gemfiles/modular/duel.gemfile | 18 ++++ gemfiles/perf.gemfile | 9 ++ 19 files changed, 608 insertions(+), 1 deletion(-) create mode 100644 .duel.rb create mode 100644 .github/workflows/perf.yml create mode 100644 duel/access_token_duel.rb create mode 100644 duel/authorize_duel.rb create mode 100644 duel/boot_duel.rb create mode 100644 duel/duel_helper.rb create mode 100644 duel/request_duel.rb create mode 100644 duel/response_duel.rb create mode 100644 duel/token_duel.rb create mode 100644 gemfiles/modular/duel.gemfile create mode 100644 gemfiles/perf.gemfile diff --git a/.duel.rb b/.duel.rb new file mode 100644 index 00000000..784bb1b8 --- /dev/null +++ b/.duel.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Duel's configuration: https://github.com/sferik/duel-ruby/blob/main/docs/configuration.md +# +# Duel follows conventions, so set only what differs: benchmarks are in +# duel/**/*_duel.rb, each version's lib/ is first on the load path, a +# change is compared when it touches lib/ or ext/ (or the benchmarks, this +# file, or the version files), and releases are tagged v*. +# +# kettle-jem creates this file when it is missing, and leaves it alone +# after that, so it is this project's to change. +Duel.configure do |c| + # Each version runs on the Ruby that its own version file names, so a + # branch that upgrades Ruby is compared on each version's own Ruby. + c.ruby = :version_file + # Compare a change to the Perf workflow too, which decides how the code + # is measured. (Duel already watches lib/, ext/, the benchmarks, this + # file, and the version files.) + c.watch += %w[.github/workflows/perf.yml] + + # When the workflow comments on a pull request with the report: :always, + # :results (passed or failed), :failures (failed, or couldn't compare), + # or :never. + # c.comment = :failures +end diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml new file mode 100644 index 00000000..fbb45e65 --- /dev/null +++ b/.github/workflows/perf.yml @@ -0,0 +1,186 @@ +name: Perf + +# Performance regression guards: Duel runs the benchmarks in duel/ on the +# baseline and on this code side by side, and fails if this code is slower. +# See .duel.rb and https://github.com/sferik/duel-ruby + +# The Duel job runs this branch's code, so it only reads. Commenting is a +# job of its own, which runs none of it. +permissions: + contents: read + +on: + pull_request: + push: + branches: + - 'main' + schedule: + - cron: "0 4 * * *" + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels a pull request's previous runs that have not yet completed. Every +# other run is a group of its own, so none is cancelled: each push to main +# is compared with the commit before it, and a cancelled run would skip +# that comparison. +concurrency: + group: "${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}" + cancel-in-progress: true + +jobs: + duel: + if: "github.event_name != 'push' || (!contains(github.event.head_commit.message, '[ci skip]') && !contains(github.event.head_commit.message, '[skip ci]'))" + name: Duel + runs-on: ubuntu-latest + # Whether the Comment job comments (post, change or none), and the + # run's status (passed, failed or neutral), from Duel. + outputs: + comment: ${{ steps.compare.outputs.comment }} + status: ${{ steps.compare.outputs.status }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + # The perf appraisal: the gemspec's dependencies and Duel (Ruby 3.4 or later) + BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/perf.gemfile + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # the baseline comes from history, tags included + persist-credentials: false + + # .duel.rb sets c.ruby = :version_file, so each version runs on the Ruby + # that its own version file names (.ruby-version, .tool-versions, or + # mise.toml, the first that names one, as Duel reads them). When the + # baseline's names another Ruby than this one's, install that Ruby too, + # and link it where Duel finds it. The baseline is the one the Compare + # step uses: the main branch for a pull request, the commit before a + # push, the last release on a schedule (the newest v* tag before HEAD, + # as Duel finds it), and where HEAD left the default branch when run by + # hand. + - name: Find the baseline's Ruby + id: baseline + env: + BEFORE: ${{ github.event.before }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + case "$GITHUB_EVENT_NAME" in + pull_request) base="origin/$GITHUB_BASE_REF" ;; + push) base="$BEFORE" ;; + schedule) base=$(git describe --tags --abbrev=0 --match 'v*' HEAD^ 2>/dev/null) || base= ;; + *) base=$(git merge-base HEAD "origin/$DEFAULT_BRANCH" 2>/dev/null) || base= ;; + esac + ruby_of() { + for file in .ruby-version .tool-versions mise.toml .mise.toml; do + text=$(git show "$1:$file" 2>/dev/null) || continue + case "$file" in + .ruby-version) version=$(printf '%s\n' "$text" | awk 'NF { print $1; exit }') ;; + .tool-versions) version=$(printf '%s\n' "$text" | awk '$1 == "ruby" { print $2; exit }') ;; + *) version=$(printf '%s\n' "$text" | awk '/^\[/ { tools = ($0 ~ /^\[tools\]/) } tools && match($0, /^[ \t]*"?ruby"?[ \t]*=[ \t]*[[{]?[ \t]*(version[ \t]*=[ \t]*)?"[^"]+"/) { s = substr($0, RSTART, RLENGTH); sub(/"$/, "", s); sub(/.*"/, "", s); print s; exit }') ;; + esac + if [ -n "$version" ]; then + echo "$version" + return + fi + done + } + ruby= + if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then + ruby=$(ruby_of "$base") + [ "$ruby" = "$(ruby_of HEAD)" ] && ruby= + fi + echo "ruby=$ruby" >> "$GITHUB_OUTPUT" + + - name: Setup the baseline's Ruby + id: baseline-ruby + if: steps.baseline.outputs.ruby != '' + uses: ruby/setup-ruby@7f797815d8adceb1c412e7abefdd1ab4abc47a5f # v1.335.0 + with: + ruby-version: ${{ steps.baseline.outputs.ruby }} + # The bundle for this Ruby too, which the baseline's workers load. + # (Gems installed for one Ruby version don't load on another.) + bundler-cache: true + + - name: Link the baseline's Ruby where Duel finds it + if: steps.baseline.outputs.ruby != '' + env: + PREFIX: ${{ steps.baseline-ruby.outputs.ruby-prefix }} + run: | + mkdir -p ~/.rubies + ln -sfn "$PREFIX" ~/.rubies/ruby-"$("$PREFIX/bin/ruby" -e 'print RUBY_VERSION')" + + # The candidate's Ruby, from its version file (the same one Duel reads) + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@7f797815d8adceb1c412e7abefdd1ab4abc47a5f # v1.335.0 + with: + bundler-cache: true + + # Against the main branch for a pull request, what was there before + # for a push, and the last release on a schedule (slow creep). Duel + # adds its report to the job's summary, writes it to duel.md for the + # comment, and sets the step's outputs to say whether to comment, as + # c.comment in .duel.rb says, whatever the outcome. + - name: Compare + id: compare + env: + DUEL_BASE: ${{ github.event_name == 'schedule' && 'last-release' || github.event_name == 'push' && github.event.before || '' }} + # (Run by hand, it compares anyway: with nothing changed, the code + # with itself, which checks the setup.) + DUEL_FORCE: ${{ github.event_name == 'workflow_dispatch' && '1' || '' }} + run: | + # (A branch's first push, or one that rewrote history, has no + # commit before it here to compare against.) + if [ "$GITHUB_EVENT_NAME" = push ] && ! git cat-file -e "$DUEL_BASE^{commit}" 2>/dev/null; then + echo "::notice::No commit before this push to compare against" + exit 0 + fi + bundle exec duel --markdown "$RUNNER_TEMP/duel.md" + + # The report, for the Comment job + - name: Keep the report + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: duel-report + path: ${{ runner.temp }}/duel.md + if-no-files-found: ignore # none when the comparison didn't run + retention-days: 1 + + # The report, as a new comment on the pull request, when Duel says to: + # always (post), or only when its last comment's status differs from + # this run's (change), so the latest comment is never stale. It never + # edits or deletes a comment. This job checks nothing out and runs none + # of the branch's code, so it alone can write. (A pull request from a + # fork gets no comment, as its token can't write one: the Duel job's + # summary has the report.) + comment: + name: Comment + needs: duel + if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && (needs.duel.outputs.comment == 'post' || needs.duel.outputs.comment == 'change') }} + runs-on: ubuntu-latest + permissions: + pull-requests: write # to comment on the pull request with the report + steps: + - name: Get the report + id: report + continue-on-error: true # the Duel job keeps no report when it fails before comparing + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: duel-report + path: ${{ runner.temp }}/report + + - name: Comment on the pull request + if: steps.report.outcome == 'success' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + COMMENT: ${{ needs.duel.outputs.comment }} + STATUS: ${{ needs.duel.outputs.status }} + run: | + marker="\")" | tail -n 1) + if [ "$COMMENT" = post ] || { [ -n "$last" ] && [ "$last" != "$marker status=$STATUS -->" ]; }; then + { echo "$marker status=$STATUS -->"; cat "$RUNNER_TEMP/report/duel.md"; } > "$RUNNER_TEMP/comment.md" + gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null + fi diff --git a/.rubocop.yml b/.rubocop.yml index 022bb32c..76623cb9 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -15,8 +15,13 @@ RBS: inherit_mode: merge: - Exclude + # Merge Include too, so .duel.rb (a dotfile) is added to the defaults + - Include AllCops: + # Duel's configuration is a dotfile, which RuboCop skips unless included + Include: + - .duel.rb DisplayCopNames: true # Display the name of the failing cops Exclude: - 'bin/*' diff --git a/.structuredmerge/kettle-jem.yml b/.structuredmerge/kettle-jem.yml index ed5e8d1a..4b40692e 100644 --- a/.structuredmerge/kettle-jem.yml +++ b/.structuredmerge/kettle-jem.yml @@ -363,6 +363,17 @@ workflows: # Include VCR/WebMock recording modular gemfiles in generated Appraisals. # Enable only for projects that actually require recording support. recording: false + # Duel performance regression guards (https://github.com/sferik/duel-ruby). + # Off by default. Set `duel: true` to generate the Perf workflow + # (.github/workflows/perf.yml), gemfiles/modular/duel.gemfile (loaded by + # the root Gemfile and a `perf` appraisal), the `rake duel` task, the + # RuboCop include for .duel.rb, and the Duel README badge. .duel.rb is + # created only when it is missing, and the benchmarks in duel/ are the + # project's own to write. Duel requires Ruby 3.4 or later: on older + # Rubies it is not installed, and `rake duel` only warns. While Duel is + # off, kettle-jem neither writes nor removes .duel.rb, perf.yml, or + # duel.gemfile, so a project's own copies stay as they are. + duel: true # preset: framework # framework_matrix: # dimension: activerecord diff --git a/Appraisals b/Appraisals index a2ac81c8..316e12d8 100644 --- a/Appraisals +++ b/Appraisals @@ -188,3 +188,10 @@ appraise "templating" do eval_gemfile "modular/templating.gemfile" eval_gemfile "modular/x_std_libs.gemfile" end + +appraise "perf" do + # Performance regression guards, for .github/workflows/perf.yml + # (Duel requires Ruby 3.4 or later) + eval_gemfile "modular/duel.gemfile" + eval_gemfile "modular/x_std_libs.gemfile" +end diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f915715..f7959d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ Please file a bug if you notice a violation of semantic versioning. ### Added +- kettle-jem-template-20261025-001 - Duel performance regression guards: the + Perf workflow compares each pull request, each push to main, and, on a + schedule, the last release, and `rake duel` compares local changes, with + the benchmarks in `duel/`. + ### Changed ### Deprecated diff --git a/Gemfile b/Gemfile index 6b083174..88913d93 100644 --- a/Gemfile +++ b/Gemfile @@ -127,6 +127,9 @@ eval_gemfile "gemfiles/modular/changelog.gemfile" # Optional eval_gemfile "gemfiles/modular/optional.gemfile" +# Performance regression guards (Duel requires Ruby 3.4 or later) +eval_gemfile "gemfiles/modular/duel.gemfile" + ### Std Lib Extracted Gems eval_gemfile "gemfiles/modular/x_std_libs.gemfile" diff --git a/Gemfile.lock b/Gemfile.lock index cd4b6967..55990c9b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -138,6 +138,8 @@ GEM dry-inflector (~> 1.0) dry-logic (~> 1.4) zeitwerk (~> 2.6) + duel (0.1.1) + fiddle (~> 1.1) emoji_regex (15.0.0) erb (6.0.7) faraday (2.15.0) @@ -146,6 +148,7 @@ GEM logger faraday-net_http (3.5.0) net-http (~> 0.5) + fiddle (1.1.8) gem_bench (2.0.5) bundler (>= 1.14) version_gem (~> 1.1, >= 1.1.4) @@ -506,6 +509,7 @@ GEM hashie (>= 0.1.0, < 6) version_gem (~> 1.1, >= 1.1.16) sorbet-runtime (0.6.13594) + stackprof (0.2.28) standard (1.57.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) @@ -633,6 +637,7 @@ DEPENDENCIES bundler-audit (~> 0.9.3) cgi (~> 0.5) debug (>= 1.11.1) + duel (~> 0.1, >= 0.1.1) erb (~> 6.0, >= 6.0.7) gem_bench (~> 2.0, >= 2.0.5) gitmoji-regex (~> 2.0, >= 2.0.15) @@ -662,6 +667,7 @@ DEPENDENCIES rubocop-packaging! rubocop-ruby2_2 (~> 3.0, >= 3.0.7) ruby-progressbar (~> 1.13) + stackprof (~> 0.2, >= 0.2.28) standard (~> 1.57, >= 1.57.0) stone_checksums (~> 1.0, >= 1.0.9) stringio (>= 3.2.0) @@ -720,10 +726,12 @@ CHECKSUMS dry-logic (1.6.0) sha256=da6fedbc0f90fc41f9b0cc7e6f05f5d529d1efaef6c8dcc8e0733f685745cea2 dry-schema (1.16.0) sha256=cd3aaeabc0f1af66ec82a29096d4c4fb92a0a58b9dae29a22b1bbceb78985727 dry-types (1.9.1) sha256=baebeecdb9f8395d6c9d227b62011279440943e3ef2468fe8ccc1ba11467f178 + duel (0.1.1) sha256=5f8f42f9a9dd1d3f2925dbb8d8f11cb9f5a8ad05d926097c17ac13a4d907d52e emoji_regex (15.0.0) sha256=a508b2c0bee561169564e0aec96085020c900520d335e5533fd8e78e59493829 erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 faraday (2.15.0) sha256=3be0eff0187ed913a1b4f679e9fcfe845b542cdd4f75b76cd6181a4ae1335fff faraday-net_http (3.5.0) sha256=2e7094c34e38754ee048cc3df47376f8657005d9375f8473bce1c3b526a22e36 + fiddle (1.1.8) sha256=7fa8ee3627271497f3add5503acdbc3f40b32f610fc1cf49634f083ef3f32eee gem_bench (2.0.5) sha256=0dc0fb44a5a5eb7b2f5c1c68a5b0164d72007132822c012bac3abe976b199ead geom2d (0.4.1) sha256=ea0998ea90c4f2752e24fe13d85a4f89bee689d151316140ebcc6369bf634ed9 gitmoji-regex (2.0.15) sha256=c6571bffd757f95c06d84a7d2d0b3f180eca7fe576882c371cee1f1a081cd968 @@ -835,6 +843,7 @@ CHECKSUMS simplecov_lcov_formatter (0.9.0) sha256=4c1839bb31dcf7a8f7a6474c94ed228aa03b994e9dd5e1965e58b4121b049bed snaky_hash (2.0.9) sha256=e93e6ec80632f8c8c4a3fe6e25d51eaef253e0e58dc7100f0f8370320feb3128 sorbet-runtime (0.6.13594) sha256=cf26f625a84c73d0d4d151fc19cafa1a6c44ad1814f2454e545893773eddd5e3 + stackprof (0.2.28) sha256=4ec2ace02f386012b40ca20ef80c030ad711831f59511da12e83b34efb0f9a04 standard (1.57.0) sha256=4a65ea92421fbaf77fb54da91e16093cf79b335b8f62c35aaa3dd714c8592858 standard-custom (1.0.2) sha256=424adc84179a074f1a2a309bb9cf7cd6bfdb2b6541f20c6bf9436c0ba22a652b standard-performance (1.9.0) sha256=49483d31be448292951d80e5e67cdcb576c2502103c7b40aec6f1b6e9c88e3f2 diff --git a/README.md b/README.md index 3804bb85..8d4f64e9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # 🔐 OAuth2 -[![Version][👽versioni]][👽version] [![Ruby Users Forum][✉️ruby-forum-top-img]][✉️ruby-forum] [![GitHub tag (latest SemVer)][⛳️tag-img]][⛳️tag] [![License: MIT][📄license-img]][📄license] [![Total downloads][👽dl-ranki]][👽dl-rank] [![CodeCov Test Coverage][🏀codecovi]][🏀codecov] [![Coveralls Test Coverage][🏀coveralls-img]][🏀coveralls] [![QLTY Test Coverage][🏀qlty-covi]][🏀qlty-cov] [![QLTY Maintainability][🏀qlty-mnti]][🏀qlty-mnt] [![CI Heads][🚎3-hd-wfi]][🚎3-hd-wf] [![CI Runtime Dependencies @ HEAD][🚎12-crh-wfi]][🚎12-crh-wf] [![CI Current][🚎11-c-wfi]][🚎11-c-wf] [![CI Truffle Ruby][🚎9-t-wfi]][🚎9-t-wf] [![CI JRuby][🚎10-j-wfi]][🚎10-j-wf] [![Deps Locked][🚎13-🔒️-wfi]][🚎13-🔒️-wf] [![Deps Unlocked][🚎14-🔓️-wfi]][🚎14-🔓️-wf] [![CI Test Coverage][🚎2-cov-wfi]][🚎2-cov-wf] [![CI Style][🚎5-st-wfi]][🚎5-st-wf] [![Apache SkyWalking Eyes License Compatibility Check][🚎15-🪪-wfi]][🚎15-🪪-wf] +[![Version][👽versioni]][👽version] [![Ruby Users Forum][✉️ruby-forum-top-img]][✉️ruby-forum] [![GitHub tag (latest SemVer)][⛳️tag-img]][⛳️tag] [![License: MIT][📄license-img]][📄license] [![Total downloads][👽dl-ranki]][👽dl-rank] [![CodeCov Test Coverage][🏀codecovi]][🏀codecov] [![Coveralls Test Coverage][🏀coveralls-img]][🏀coveralls] [![QLTY Test Coverage][🏀qlty-covi]][🏀qlty-cov] [![QLTY Maintainability][🏀qlty-mnti]][🏀qlty-mnt] [![CI Heads][🚎3-hd-wfi]][🚎3-hd-wf] [![CI Runtime Dependencies @ HEAD][🚎12-crh-wfi]][🚎12-crh-wf] [![CI Current][🚎11-c-wfi]][🚎11-c-wf] [![CI Truffle Ruby][🚎9-t-wfi]][🚎9-t-wf] [![CI JRuby][🚎10-j-wfi]][🚎10-j-wf] [![Deps Locked][🚎13-🔒️-wfi]][🚎13-🔒️-wf] [![Deps Unlocked][🚎14-🔓️-wfi]][🚎14-🔓️-wf] [![CI Test Coverage][🚎2-cov-wfi]][🚎2-cov-wf] [![CI Style][🚎5-st-wfi]][🚎5-st-wf] [![Apache SkyWalking Eyes License Compatibility Check][🚎15-🪪-wfi]][🚎15-🪪-wf] [![Duel][⚔️duel-img]][⚔️duel] `if ci_badges.map(&:color).detect { it != "green"}` ☝️ [let me know on Discord][✉️discord-invite] or [RubyForum][✉️ruby-forum], as I may have missed the notification. @@ -1094,6 +1094,8 @@ Thanks for RTFM. ☺️ [📄license-compat]: https://www.apache.org/legal/resolved.html#category-a [📄license-compat-img]: https://img.shields.io/badge/Apache_Compatible:_Category_A-%E2%9C%93-259D6C.svg?style=flat&logo=Apache +[⚔️duel]: https://github.com/sferik/duel-ruby +[⚔️duel-img]: https://raw.githubusercontent.com/sferik/duel-ruby/main/assets/badge.svg [📄ilo-declaration]: https://www.ilo.org/declaration/lang--en/index.htm [📄ilo-declaration-img]: https://img.shields.io/badge/ILO_Fundamental_Principles-✓-259D6C.svg?style=flat [🚎yard-current]: http://rubydoc.info/gems/oauth2 diff --git a/Rakefile b/Rakefile index 572e89ec..8e0854c6 100644 --- a/Rakefile +++ b/Rakefile @@ -181,6 +181,20 @@ if kettle_jem_selftest_unavailable_note end end +### PERFORMANCE TASKS +# Duel's performance regression guards: rake duel compares this tree with +# the main branch (see .duel.rb and duel/). Duel requires Ruby 3.4 or +# later, so older Rubies get a stub task. +begin + require "duel/rake_task" + Duel::RakeTask.new +rescue LoadError + desc("(stub) duel is unavailable") + task("duel") do + warn("NOTE: duel isn't installed, or is disabled for #{RUBY_VERSION} in the current environment") + end +end + ### RELEASE TASKS # Setup stone_checksums begin diff --git a/duel/access_token_duel.rb b/duel/access_token_duel.rb new file mode 100644 index 00000000..7ba66533 --- /dev/null +++ b/duel/access_token_duel.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative "duel_helper" + +# AccessToken on its own: apps rebuild tokens from stored hashes, and +# store them again, far more often than they fetch new ones + +duel "access_token: build from a token hash" do + once do + @client = oauth_client + @hash = JSON.parse(token_body) + end + OAuth2::AccessToken.from_hash(@client, @hash) +end + +duel "access_token: build from a large token hash" do + once do + @client = oauth_client + @hash = JSON.parse(oidc_token_body) + 40.times { |i| @hash["custom_claim_#{i}"] = "value #{i}" } + end + OAuth2::AccessToken.from_hash(@client, @hash) +end + +duel "access_token: serialize with to_hash" do + once do + hash = JSON.parse(oidc_token_body).merge("expires_at" => 1_767_229_200) + hash.delete("expires_in") + @token = OAuth2::AccessToken.from_hash(oauth_client, hash) + end + @token.to_hash +end diff --git a/duel/authorize_duel.rb b/duel/authorize_duel.rb new file mode 100644 index 00000000..4b41ba04 --- /dev/null +++ b/duel/authorize_duel.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require_relative "duel_helper" + +# The URL that an app redirects each user to, built once per sign-in +duel "authorize: a URL with the usual params" do + once { @client = oauth_client } + @client.auth_code.authorize_url( + scope: "openid profile email", + state: "af0ifjsldkj", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + code_challenge_method: "S256" + ) +end + +duel "authorize: a URL with many params" do + once do + @client = oauth_client + @params = { + scope: "openid profile email address phone offline_access https://api.example.com/read https://api.example.com/write", + state: "af0ifjsldkj", + nonce: "n-0S6_WzA2Mj", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + code_challenge_method: "S256", + prompt: "consent select_account", + max_age: 3600, + ui_locales: "en-US fr-CA", + login_hint: "user@example.com", + acr_values: "urn:mace:incommon:iap:silver", + response_mode: "query", + display: "page", + access_type: "offline", + include_granted_scopes: true, + claims: JSON.generate("userinfo" => {"email" => {"essential" => true}, "email_verified" => nil}), + audience: "https://api.example.com", + } + end + @client.auth_code.authorize_url(@params) +end diff --git a/duel/boot_duel.rb b/duel/boot_duel.rb new file mode 100644 index 00000000..7e06e584 --- /dev/null +++ b/duel/boot_duel.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +# Loading the library, which every process that uses it pays once +duel_process "boot: require oauth2", ruby: %(require "oauth2") diff --git a/duel/duel_helper.rb b/duel/duel_helper.rb new file mode 100644 index 00000000..0c5f9ec3 --- /dev/null +++ b/duel/duel_helper.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +# Shared setup for the benchmarks in duel/. Every request goes to +# Faraday's in-process test adapter, so no benchmark waits on a socket +# and every iteration sees the same responses. + +require "json" +require "faraday" +require "multi_xml" + +# Pin the process-wide choices that would otherwise depend on what an +# earlier benchmark (or the bundle) happened to load: the XML backend +# that MultiXml picks, and the adapter that Faraday defaults to. +(defined?(MultiXML) ? MultiXML : MultiXml).parser = :rexml +Faraday.default_adapter = :test + +DUEL_SITE = "https://auth.example.com" +DUEL_REDIRECT_URI = "https://app.example.com/oauth/callback" +DUEL_ACCESS_TOKEN = "eyJhY2Nlc3MiOiJ0b2tlbiJ9.c3RhYmxlLWFjY2Vzcy10b2tlbi1mb3ItYmVuY2htYXJrcw" +DUEL_REFRESH_TOKEN = "def50200a1b2c3d4e5f60718293a4b5c6d7e8f9012345678" + +# A typical token endpoint response (RFC 6749, section 5.1) +def token_body + JSON.generate( + "access_token" => DUEL_ACCESS_TOKEN, + "token_type" => "Bearer", + "expires_in" => 3600, + "refresh_token" => DUEL_REFRESH_TOKEN, + "scope" => "openid profile email" + ) +end + +# An OpenID Connect token response, with an ID token and the extra +# fields that providers add +def oidc_token_body + JSON.generate( + "access_token" => DUEL_ACCESS_TOKEN, + "token_type" => "Bearer", + "expires_in" => 3600, + "refresh_token" => DUEL_REFRESH_TOKEN, + "scope" => "openid profile email offline_access https://api.example.com/read https://api.example.com/write", + "id_token" => "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMyJ9.#{"eyJzdWIiOiIxMjM0NTY3ODkwIn0" * 24}.#{"c2lnbmF0dXJl" * 28}", + "ext_expires_in" => 3600, + "refresh_token_expires_in" => 86_400, + "not-before-policy" => 0, + "session_state" => "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "userId" => "1234567890", + "accountType" => "personal" + ) +end + +# A resource that an API returns to an access token: a page of records +def resource_body(records) + JSON.generate( + "total" => records, + "nextPage" => nil, + "items" => Array.new(records) do |i| + { + "id" => i, + "displayName" => "User #{i}", + "emailAddress" => "user#{i}@example.com", + "createdAt" => "2026-01-01T00:00:00Z", + "isActive" => i.even?, + "roleNames" => %w[reader writer], + } + end + ) +end + +def json_headers + {"Content-Type" => "application/json; charset=utf-8"} +end + +# Stubs for every endpoint that the benchmarks call +def endpoint_stubs + Faraday::Adapter::Test::Stubs.new do |stub| + stub.post("/oauth/token") { [200, json_headers, token_body] } + stub.post("/oidc/token") { [200, json_headers, oidc_token_body] } + stub.post("/oauth/revoke") { [200, json_headers, ""] } + stub.get("/api/me") { [200, json_headers, resource_body(1)] } + stub.get("/old/me") { [302, {"Location" => "/api/me"}, ""] } + end +end + +# A client like the ones apps build once and keep, wired to the stubs +def oauth_client(**options) + stubs = endpoint_stubs + OAuth2::Client.new( + "s6BhdRkqt3", + "7Fjfp0ZBr1KtDRbnfVdmIw", + {site: DUEL_SITE, redirect_uri: DUEL_REDIRECT_URI}.merge(options) + ) do |builder| + builder.request(:url_encoded) + builder.adapter(:test, stubs) + end +end + +# A raw Faraday response, as the client's connection returns it +def faraday_response(body, headers) + Faraday::Response.new(status: 200, response_headers: headers, body: body) +end diff --git a/duel/request_duel.rb b/duel/request_duel.rb new file mode 100644 index 00000000..07b485bb --- /dev/null +++ b/duel/request_duel.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require_relative "duel_helper" + +# What an app does with its token on every API call: sign the request, +# send it through the client, and parse the response + +duel "request: GET a resource with a bearer token" do + once do + @token = OAuth2::AccessToken.new(oauth_client, DUEL_ACCESS_TOKEN) + @token.get("/api/me").parsed + end + @token.get("/api/me").parsed +end + +duel "request: GET a resource with a query token" do + once do + @token = OAuth2::AccessToken.new(oauth_client, DUEL_ACCESS_TOKEN, mode: :query) + @token.get("/api/me").parsed + end + @token.get("/api/me").parsed +end + +duel "request: follow a redirect" do + once do + @token = OAuth2::AccessToken.new(oauth_client, DUEL_ACCESS_TOKEN) + @token.get("/old/me") + end + @token.get("/old/me") +end diff --git a/duel/response_duel.rb b/duel/response_duel.rb new file mode 100644 index 00000000..488cebcb --- /dev/null +++ b/duel/response_duel.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require_relative "duel_helper" + +# Response#parsed: picking a parser from the content type, parsing the +# body, and converting a Hash to a SnakyHash. Each iteration wraps the +# same Faraday response in a new Response, since parsed is memoized. + +duel "response: parse a JSON token body" do + once { @raw = faraday_response(token_body, json_headers) } + OAuth2::Response.new(@raw).parsed +end + +duel "response: parse a large JSON body" do + once { @raw = faraday_response(resource_body(100), json_headers) } + OAuth2::Response.new(@raw).parsed +end + +duel "response: parse a form-encoded token body" do + once do + body = "access_token=#{DUEL_ACCESS_TOKEN}&token_type=bearer&expires_in=3600&refresh_token=#{DUEL_REFRESH_TOKEN}&scope=read+write" + @raw = faraday_response(body, {"Content-Type" => "application/x-www-form-urlencoded"}) + end + OAuth2::Response.new(@raw).parsed +end + +duel "response: parse an XML token body" do + once do + body = "#{DUEL_ACCESS_TOKEN}" \ + "bearer3600#{DUEL_REFRESH_TOKEN}" + @raw = faraday_response(body, {"Content-Type" => "application/xml"}) + end + OAuth2::Response.new(@raw).parsed +end + +duel "error: build from an error response" do + once do + body = JSON.generate("error" => "invalid_grant", "error_description" => "The authorization code has expired") + @raw = Faraday::Response.new(status: 400, response_headers: json_headers, body: body) + end + OAuth2::Error.new(OAuth2::Response.new(@raw)) +end diff --git a/duel/token_duel.rb b/duel/token_duel.rb new file mode 100644 index 00000000..da93dae1 --- /dev/null +++ b/duel/token_duel.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require_relative "duel_helper" + +# The token endpoint, once per sign-in or refresh: building the request, +# authenticating the client, and turning the response into an AccessToken + +duel "token: exchange an auth code" do + once do + @client = oauth_client + @client.auth_code.get_token("SplxlOBeZQQYbYS6WxSbIA") # warms the stubs + end + @client.auth_code.get_token("SplxlOBeZQQYbYS6WxSbIA") +end + +duel "token: exchange a code for an OIDC token" do + once do + @client = oauth_client(token_url: "/oidc/token") + @client.auth_code.get_token("SplxlOBeZQQYbYS6WxSbIA") + end + @client.auth_code.get_token("SplxlOBeZQQYbYS6WxSbIA") +end + +duel "token: client credentials in the body" do + once do + @client = oauth_client(auth_scheme: :request_body) + @client.client_credentials.get_token(scope: "api.read") + end + @client.client_credentials.get_token(scope: "api.read") +end + +duel "token: refresh an access token" do + once do + client = oauth_client + @token = OAuth2::AccessToken.new(client, DUEL_ACCESS_TOKEN, refresh_token: DUEL_REFRESH_TOKEN, expires_in: 3600) + @token.refresh + end + @token.refresh +end + +duel "token: a JWT bearer assertion" do + once do + @client = oauth_client + @claims = { + iss: "s6BhdRkqt3", + sub: "s6BhdRkqt3", + aud: "#{DUEL_SITE}/oauth/token", + iat: 1_767_225_600, + exp: 1_767_229_200, + jti: "id123456", + } + @encoding = {algorithm: "HS256", key: "a-shared-secret-of-at-least-32-bytes!", kid: "key-1"} + @client.assertion.get_token(@claims, @encoding) + end + @client.assertion.get_token(@claims, @encoding) +end + +duel "token: revoke an access token" do + once do + client = oauth_client + @token = OAuth2::AccessToken.new(client, DUEL_ACCESS_TOKEN, refresh_token: DUEL_REFRESH_TOKEN) + @token.revoke + end + @token.revoke +end diff --git a/gemfiles/modular/duel.gemfile b/gemfiles/modular/duel.gemfile new file mode 100644 index 00000000..45bbe64a --- /dev/null +++ b/gemfiles/modular/duel.gemfile @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# To retain during kettle-jem templating: +# kettle-jem:freeze +# # ... your code +# kettle-jem:unfreeze +# + +# Performance regression guards (see .duel.rb and duel/) +# Duel requires Ruby 3.4 or later, so older Rubies install neither gem. +if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.4") + gem "duel", "~> 0.1", ">= 0.1.1" # ruby >= 3.4 + + # For profiles of regressions (StackProf runs on CRuby only) + platform :mri do + gem "stackprof", "~> 0.2", ">= 0.2.28" + end +end diff --git a/gemfiles/perf.gemfile b/gemfiles/perf.gemfile new file mode 100644 index 00000000..2ca61581 --- /dev/null +++ b/gemfiles/perf.gemfile @@ -0,0 +1,9 @@ +# This file was generated by Appraisal2 + +source "https://gem.coop" + +gemspec path: "../" + +eval_gemfile("modular/duel.gemfile") + +eval_gemfile("modular/x_std_libs.gemfile") From 7dbcc115783c83fe0f441e8ea231ae6b9af14606 Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Sun, 25 Oct 2026 16:37:53 -0700 Subject: [PATCH 2/2] Replace Erik Michaels-Ober with Erik Berlin Update the name in the license, the README, the gemspec's authors and the generated docs. --- LICENSE.md | 2 +- README.md | 2 +- docs/file.LICENSE.html | 2 +- docs/file.README.html | 2 +- docs/index.html | 2 +- oauth2.gemspec | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/LICENSE.md b/LICENSE.md index 67fba766..94237442 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -7,7 +7,7 @@ Choose the option that best fits your use case: ## Copyright Notice -- Copyright (c) 2010-2014, 2016-2017 Erik Michaels-Ober +- Copyright (c) 2010-2014, 2016-2017 Erik Berlin - Copyright (c) 2010 Jeremy Kemper - Copyright (c) 2010-2011 Michael Bleigh - Copyright (c) 2010-2011 Paul Walker diff --git a/README.md b/README.md index 8d4f64e9..0f64502c 100644 --- a/README.md +++ b/README.md @@ -773,7 +773,7 @@ See [LICENSE.md][📄license] for the official copyright notice.
Copyright holders -- Copyright (c) 2010-2014, 2016-2017 Erik Michaels-Ober +- Copyright (c) 2010-2014, 2016-2017 Erik Berlin - Copyright (c) 2010 Jeremy Kemper - Copyright (c) 2010-2011, 2014 Michael Bleigh - Copyright (c) 2010-2011 Paul Walker diff --git a/docs/file.LICENSE.html b/docs/file.LICENSE.html index 0ae3cd4c..6b5b130a 100644 --- a/docs/file.LICENSE.html +++ b/docs/file.LICENSE.html @@ -71,7 +71,7 @@
    -
  • Copyright (c) 2010-2014, 2016-2017 Erik Michaels-Ober
  • +
  • Copyright (c) 2010-2014, 2016-2017 Erik Berlin
  • Copyright (c) 2010 Jeremy Kemper
  • Copyright (c) 2010-2011 Michael Bleigh
  • Copyright (c) 2010-2011 Paul Walker
  • diff --git a/docs/file.README.html b/docs/file.README.html index de09d7dc..87a69cb3 100644 --- a/docs/file.README.html +++ b/docs/file.README.html @@ -1115,7 +1115,7 @@ Copyright holders
      -
    • Copyright (c) 2010-2014, 2016-2017 Erik Michaels-Ober
    • +
    • Copyright (c) 2010-2014, 2016-2017 Erik Berlin
    • Copyright (c) 2010 Jeremy Kemper
    • Copyright (c) 2010-2011, 2014 Michael Bleigh
    • Copyright (c) 2010-2011 Paul Walker
    • diff --git a/docs/index.html b/docs/index.html index 73f31d4d..dcbcfa53 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1115,7 +1115,7 @@ Copyright holders
        -
      • Copyright (c) 2010-2014, 2016-2017 Erik Michaels-Ober
      • +
      • Copyright (c) 2010-2014, 2016-2017 Erik Berlin
      • Copyright (c) 2010 Jeremy Kemper
      • Copyright (c) 2010-2011, 2014 Michael Bleigh
      • Copyright (c) 2010-2011 Paul Walker
      • diff --git a/oauth2.gemspec b/oauth2.gemspec index c7119f4a..22202aa3 100644 --- a/oauth2.gemspec +++ b/oauth2.gemspec @@ -24,7 +24,7 @@ gem_version = Gem::Specification.new do |spec| spec.name = "oauth2" spec.version = gem_version - spec.authors = ["Erik Michaels-Ober", "Jeremy Kemper", "Michael Bleigh", "Paul Walker", "rick", "Tim Habermaas", "Wynn Netherland", "Alexander Lang", "Greg Spurrier", "Jay Adkisson", "Luke Saunders", "Simon Gate", "Bas Vodde", "Damian Janowski", "Daniël van de Burgt", "Dorren Chen", "Igor Sales", "Leigh Caplan", "Michael Andrews", "Omer Rauchwerger", "Saverio Trioni", "Trent Ogren", "Vsevolod Romashov", "Antonio Tapiador del Dujo", "Eduardo Gurgel", "Geostellar Developer", "Niels Ganser", "Rainux Luo", "Taylor Hedberg", "Tim Clem", "Dave Stevens", "Ellis Berner", "Frank Macreery", "Olivier Lacan", "Peter Souter", "Ryan Williams", "Andrew Cantino and Jeff Moore", "Thomas Walpole", "Bo Jeanes", "Cody Cutrer", "Edward Rudd", "Lawrence Oluyede", "Linus Pettersson", "Motoshi Nishihira", "Adrian Setyadi", "Benjamin Quorning", "Christoph Petschnig", "Nathaniel Bibler", "Oleg", "Samuel Cochran", "tetsuya", "Yury Velikanau", "Alex Kowalczuk", "asm__", "David Christensen", "fossabot", "Jeff Moore", "Jonathan del Strother", "Joseph Page", "Lomey", "Markus Bengts", "Mathias Klippinge", "nikz", "Peter H. Boling", "Daniel Fockler", "Elliot Crosby-McCullough", "João Paulo", "Orien Madgwick", "Ryan T. Hosford", "Tom Corley", "anvox", "Jesse Cotton", "Olle Jonsson", "Stephen Reid", "Anders Carling", "dobon", "Jan Zaydowicz", "Nicholas Palaniuk", "Stan Hu", "Bouke van der Bijl", "nov", "Rick Selby", "Ryo Takahashi", "Jessie Young", "Карим Гимадеев", "Annibelle Boling", "Elise Wood", "Manuel van Rijn", "Mark James", "Mridang Agarwalla", "Sasa Rosic", "Jonathan Grinstead", "kain", "Rob Zolkos", "StepSecurity Bot", "Daniel Pepper", "iamibi", "makssein", "Martin Vlach", "Mubelotix"] + spec.authors = ["Erik Berlin", "Jeremy Kemper", "Michael Bleigh", "Paul Walker", "rick", "Tim Habermaas", "Wynn Netherland", "Alexander Lang", "Greg Spurrier", "Jay Adkisson", "Luke Saunders", "Simon Gate", "Bas Vodde", "Damian Janowski", "Daniël van de Burgt", "Dorren Chen", "Igor Sales", "Leigh Caplan", "Michael Andrews", "Omer Rauchwerger", "Saverio Trioni", "Trent Ogren", "Vsevolod Romashov", "Antonio Tapiador del Dujo", "Eduardo Gurgel", "Geostellar Developer", "Niels Ganser", "Rainux Luo", "Taylor Hedberg", "Tim Clem", "Dave Stevens", "Ellis Berner", "Frank Macreery", "Olivier Lacan", "Peter Souter", "Ryan Williams", "Andrew Cantino and Jeff Moore", "Thomas Walpole", "Bo Jeanes", "Cody Cutrer", "Edward Rudd", "Lawrence Oluyede", "Linus Pettersson", "Motoshi Nishihira", "Adrian Setyadi", "Benjamin Quorning", "Christoph Petschnig", "Nathaniel Bibler", "Oleg", "Samuel Cochran", "tetsuya", "Yury Velikanau", "Alex Kowalczuk", "asm__", "David Christensen", "fossabot", "Jeff Moore", "Jonathan del Strother", "Joseph Page", "Lomey", "Markus Bengts", "Mathias Klippinge", "nikz", "Peter H. Boling", "Daniel Fockler", "Elliot Crosby-McCullough", "João Paulo", "Orien Madgwick", "Ryan T. Hosford", "Tom Corley", "anvox", "Jesse Cotton", "Olle Jonsson", "Stephen Reid", "Anders Carling", "dobon", "Jan Zaydowicz", "Nicholas Palaniuk", "Stan Hu", "Bouke van der Bijl", "nov", "Rick Selby", "Ryo Takahashi", "Jessie Young", "Карим Гимадеев", "Annibelle Boling", "Elise Wood", "Manuel van Rijn", "Mark James", "Mridang Agarwalla", "Sasa Rosic", "Jonathan Grinstead", "kain", "Rob Zolkos", "StepSecurity Bot", "Daniel Pepper", "iamibi", "makssein", "Martin Vlach", "Mubelotix"] spec.email = ["floss@galtzo.com"] spec.summary = "🔐 OAuth 2.0, 2.1 & OIDC Core Ruby implementation"