Skip to content

Sync fork with upstream wmlele/devise-otp + Castle deltas (2.0.0.castle.0)#8

Draft
bartes wants to merge 160 commits into
masterfrom
2.0.0.castle
Draft

Sync fork with upstream wmlele/devise-otp + Castle deltas (2.0.0.castle.0)#8
bartes wants to merge 160 commits into
masterfrom
2.0.0.castle

Conversation

@bartes
Copy link
Copy Markdown

@bartes bartes commented May 25, 2026

What and Why

This PR resyncs Castle's heavily-diverged fork of devise-otp (last meaningful change was 5 years ago, while upstream shipped two breaking-change releases — v0.7.0 confirmation-required enable flow, v2.0.0 browser-persistence split + module rename) with upstream wmlele/devise-otp master (HEAD at c2b96ed, 9 commits past v2.0.0).

By piggy-backing on the freshest upstream we get Rails 8.1, Devise 5, Ruby 4 CI, Hotwire/Turbo compat, Lockable integration, server-side SVG QR codes, the Refreshable Warden hook, Rubocop + ERB Lint, and Appraisal-based multi-Rails testing — all without us having to maintain any of it.

On top of upstream we keep only four Castle-only commits, each with a clear, narrow scope.

Branch is named 2.0.0.castle so web can pin it directly:

gem 'devise-otp', castle_github: 'devise-otp', branch: '2.0.0.castle'

(replacing the now-stale branch: 'rails8').

Castle-only commits (on top of upstream master)

Commit What
a91c1a7 chore: pin Ruby 3.3.10 via .tool-versions — align with web
287693a chore: mark this as 2.0.0.castle.0 + add fork notice — version + README header
0c14e99 chore: drop standardrb placeholder gem (inoperative) — removes 4 dead deps
72c826c docs: add CHANGELOG entry for 2.0.0.castle.0

Known limitations / heads-up for the web migration

Adopting this branch in web is a breaking change at the call-site level (separate PR will follow):

  • Route module rename Devise::Otp::*DeviseOtp::* — affects users/tokens_controller.rb, users/credentials_controller.rb.
  • reset_otp_credentials! is removed upstream — replace with clear_otp_fields!. Used in app/admin/user.rb, users/tokens_controller.rb, services/users/invite_to_organization.rb.
  • is_otp_trusted_device_for? renamed to is_otp_trusted_browser_for? (used in views/devise/otp/tokens/_trusted_devices.html.haml).

web's Users::SessionsController#otp_create already mirrors the upstream DeviseOtpAuthenticatable::Hooks::Sessions#create flow, so no semantic differences there.

Test plan

  • bundle install resolves cleanly with Ruby 3.3.10 + Devise 5.0.4 + Rails 8.1.3 (no "BIG SCARY WARNING" anymore)
  • bundle exec rake test68 tests, 188 assertions, 0 failures, 0 errors, 0 skips
  • CI green across the full Ruby × Rails matrix (upstream's .github/workflows/ci.yml: Ruby 3.2/3.3/3.4/4.0/head × Rails 7.1/7.2/8.0/8.1)
  • Validate in web against a real OTP login flow before merging this PR (kept as draft until then)

Jonathan Simmons and others added 30 commits July 28, 2015 21:49
- Unify the term "trusted browser" vs "trusted device"
- Allow browser to be set as trusted when providing token
- Update yml to reflect browser change
  - Remove the hard coded timeframe from `trusted_browser.explain`
Support Rails 5 and Devise 4.2.x
…sted-browser-changes

Trusted browser updates
Simplify the flow by not requiring the OTP code on refresh
strouptl and others added 30 commits August 17, 2025 15:34
- Update OTP Credentials controller to execute the valid_for_authenitcation? method
- - Checks if account is locked
- - Increments failed attempts and/or locks account as needed
- Remove resource.valid_otp_challenge? check from OTP Credentials controller
- - Already covered by find_valid_otp_challenge
- Add tests to confirm updated functionality
- Update sign_in form to pass "Remember me" selection through to OTP Credentials form via otp_challenge_path redirect;
- Remove second, persistence related "Remember me" checkbox from OTP Credentials form;
- Add tests to confirm functionality;
…ele#140)

- Move browser persistence actions to dedicated controller
- Standardize browser persistence routes (post/delete rather than get/post)
- Use button_to (rather than link_to) for controls to reduce JS dependency
- Add tests for removing and clearing browser persistence
- Update OTP Credentials controller to apply remember_me value even when skipping challenge due to browser persistence;
- Add test to confirm resolution;
Align this gem's Ruby version with the consumer (Castle's `web` app
runs Ruby 3.3.10) so local development and tooling (asdf) pick the
same interpreter when contributors switch between repos.

Upstream's `required_ruby_version` is `>= 3.2.0`, so 3.3.10 is well
within range; CI continues to test against 3.2 / 3.3 / 3.4 / 4.0.
- Bump VERSION to `2.0.0.castle.0` so the gem is visibly distinct from
  upstream `2.0.0` when resolved from Bundler. Subsequent Castle-only
  patches should bump the `castle.N` suffix.
- Add a fork notice block at the top of the README pointing back to
  `wmlele/devise-otp` and listing the current Castle-only deltas, so
  it's clear at a glance what this fork carries and when it should be
  re-synced.
\`standardrb 1.35.0.1\` is an inoperative placeholder gem that prints
a "this gem does nothing — pin a real version" warning on every
\`bundle install\`. Upstream's Gemfile lists it alongside
\`rubocop-rails-omakase\` and \`erb_lint\`, but nothing in the source
tree, bin/, or .github/ actually invokes \`standardrb\` — confirmed
via \`rg "standardrb"\`.

The remaining formatters (\`rubocop-rails-omakase\` + \`erb_lint\`) are
sufficient for CI and local linting. \`bundle install\` now installs
112 gems instead of 116 and emits no warnings.

Removed from \`Gemfile\` and from all four \`gemfiles/rails_*.gemfile\`
files since those are committed (not regenerated on CI). When the
upstream Gemfile drops standardrb (or pins it to a working version),
this commit can be reverted as part of a re-sync.
Document the initial Castle fork release at the top of the existing
upstream CHANGELOG. Lists what this fork carries on top of upstream
master and the re-sync policy.
The CI matrix lists Ruby 3.2 / 3.3 / 3.4 / 4.0 / head, but upstream
left two stale \`exclude\` entries referencing Ruby 3.1 (\`3.1 ×
rails_8.0\` and \`3.1 × rails_8.1\`). Those have been no-ops since the
matrix was renamed; the gemspec already enforces
\`required_ruby_version >= 3.2.0\` independently.

Removing the dead config so the workflow file actually reflects what
we test against.
Upstream's \`.gitignore\` doesn't cover the local bundler-install path
(\`vendor/bundle/\`) or the sqlite3 dummy database files. Running
\`bundle install\` locally then \`git status\` floods with thousands of
untracked vendored gem files; \`git add -A\` accidentally sweeps them
in (~557MB).

Adding the two patterns so the working tree stays clean. Both are
genuinely build artifacts, not source.
\`appraisal\` was used as a regenerator for the per-Rails-version
\`gemfiles/rails_*.gemfile\` files, but CI never invokes it — the
workflow sets \`BUNDLE_GEMFILE\` directly. Outside of \`bundle exec
appraisal generate\` in dev, the gem was unused, and the upstream
Gemfile pinned it to a *git ref*
(\`git: "https://github.com/thoughtbot/appraisal.git"\`) which is
fragile: any new commit on thoughtbot/appraisal master can break our
\`bundle install\`.

This commit:

- Removes \`gem "appraisal"\` from \`Gemfile\` and from all four
  \`gemfiles/rails_*.gemfile\` files.
- Deletes the \`Appraisals\` DSL file. The per-Rails dep pins it
  contained (e.g. the Ruby 3.4+ \`logger\` workaround for Rails 7.1)
  are already rendered into the gemfiles.
- Deletes \`bin/appraisal\` (binstub).
- Updates README + CHANGELOG to reflect the change.

The CI matrix is unchanged — it still tests Ruby 3.2/3.3/3.4/4.0/head
\u00d7 Rails 7.1/7.2/8.0/8.1, just without appraisal as middleware.
Mirrors the same approach \`castle_devise\` took in its 0.6.0 release.

Going forward, when bumping a Rails version, edit the corresponding
\`gemfiles/rails_X.Y.gemfile\` by hand (or copy from another version).
\`web\`'s \`.tool-versions\` lists both \`ruby 3.3.10\` and
\`nodejs 22.22.3\`. Adding the nodejs line here keeps the two repos in
lockstep so contributors switching between them don't have asdf
reconfiguring node versions.

The gem itself has no JS / asset build pipeline, so nodejs isn't
strictly required to develop on devise-otp \u2014 this is purely a tooling
ergonomics alignment.
The previous pattern \`vendor/bundle/\` was anchored to the repo root,
so \`gemfiles/vendor/bundle/\` (created when running
\`BUNDLE_GEMFILE=gemfiles/rails_X.Y.gemfile bundle install\`) wasn't
matched. Using \`**/vendor/bundle/\` covers both paths.
Gemspec allows \`devise >= 4.8.0, < 6.0\`, but none of the per-Rails
gemfiles pin a Devise version \u2014 bundler always picks the latest
matching, so today every row in the CI matrix tests only Devise
5.0.x. Devise 4 compatibility is asserted by the gemspec but never
actually verified.

Pin per Rails era to exercise both:

- gemfiles/rails_7.1.gemfile \u2192 devise ~> 4.9  (Devise 4 era)
- gemfiles/rails_7.2.gemfile \u2192 devise ~> 4.9
- gemfiles/rails_8.0.gemfile \u2192 devise ~> 5.0  (Devise 5 era)
- gemfiles/rails_8.1.gemfile \u2192 devise ~> 5.0

Result: CI now verifies 8 distinct (Rails, Devise) combinations \u00d7 5
Ruby versions. Cross-era combos (Devise 4 on Rails 8, Devise 5 on
Rails 7) are intentionally not tested \u2014 they're not in any consumer's
use case and would double the matrix.

Also pin \`devise ~> 5.0\` in the top-level \`Gemfile\` so local
\`bundle install\` and the dev test run default to the new major.
Switching to Devise 4 locally is a single \`BUNDLE_GEMFILE=gemfiles/
rails_7.1.gemfile bundle exec rake test\` invocation.

Both paths verified locally:
- Devise 4.9.4 \u00d7 Rails 7.1.6: 68 tests, 188 assertions, 0 failures
- Devise 5.0.4 \u00d7 Rails 8.1.3: 68 tests, 188 assertions, 0 failures
Refreshable hook
- Upstream's hook guards on `defined?(record.class.otp_credentials_refresh)`,
  which is always truthy for any model that includes `:otp_authenticatable`
  (Devise::Models.config defines the accessor on the class). When
  `otp_credentials_refresh` was set to `false`, the hook still ran and
  attempted `Time.now + false`, raising `TypeError` on every sign-in.
- Switch to a truthy-value check on the resolved setting so a disabled
  refresh short-circuits the assignment.

otp_challenge_via_strategy config
- The 2.0.0 redesign moved the OTP challenge redirect into
  `Devise::Strategies::DatabaseAuthenticatable`. That runs during
  `warden.authenticate!` and prevents applications from doing additional
  work between authentication and the OTP redirect (e.g. a risk check
  that decides which OTP path to take, or applying a custom redirect host).
- Add `Devise.otp_challenge_via_strategy` (default `true`, preserving
  upstream behaviour). When `false`, the strategy succeeds without
  redirecting and the controller is responsible for driving the OTP
  challenge — matching the pre-2.0.0 flow.

Bumps VERSION to 2.0.0.castle.1.
Upstream 2.0.0 stopped shipping any JavaScript — the QR code is now
rendered server-side as an inline SVG via `rqrcode` inside
`otp_authenticator_token_image`. The engine still registered
`devise-otp.js` for asset precompile though, pointing at a file that no
longer exists. Any app running `assets:precompile` on Sprockets >= 4
would inherit the registration and crash with
`Sprockets::FileNotFound: couldn't find file 'devise-otp.js'`.

This commit:

  * Removes the `app.config.assets.precompile << "devise-otp.js"` block.
  * Removes the now-unused `config.devise_otp.precompile_assets` opt-out
    (its only consumer was the block above).

`app/assets/stylesheets/devise-otp.css` is left in place — it's still
referenced by the `qrcode-container` wrapper the helper emits — but
nothing was auto-registering it for precompile before either, so apps
that want it bundled need to opt in via their own `assets.precompile`
list (unchanged behaviour).

Bumps VERSION to 2.0.0.castle.2 and adds a CHANGELOG entry.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.