Skip to content

Add 2FA interface for extension gems to hook into#5843

Open
santiagorodriguez96 wants to merge 9 commits intoheartcombo:mainfrom
cedarcode:sr--2fa-interface
Open

Add 2FA interface for extension gems to hook into#5843
santiagorodriguez96 wants to merge 9 commits intoheartcombo:mainfrom
cedarcode:sr--2fa-interface

Conversation

@santiagorodriguez96
Copy link
Copy Markdown

@santiagorodriguez96 santiagorodriguez96 commented Apr 28, 2026

Motivation

Closes #5842.

How it works

The application opts in by adding :two_factor_authenticatable to the model and listing the methods it wants to enable:

class User < ApplicationRecord
  devise :database_authenticatable, :two_factor_authenticatable,
         two_factor_methods: [:webauthn]
end

Extension gems register themselves via a single API:

# In an extension's engine
Warden::Strategies.add(:webauthn_two_factor, Devise::Strategies::WebauthnTwoFactor)

Devise.register_two_factor_method :webauthn,
  model: "devise/models/webauthn_two_factor",
  strategy: :webauthn_two_factor

register_two_factor_method adds the strategy to Devise's STRATEGIES map and stores the model concern path in a registry. When the user model is loaded, two_factor_methods= looks each method up in that registry and includes the corresponding model concern – so each extension brings its own model module without needing to be a full Devise module itself.

Sign in flow

  1. User submits their password. DatabaseAuthenticatable validates it as before.
  2. If validation succeeds and the resource has 2FA enabled (see the conventions below), the strategy stores the resource id and remember_me in the session and redirects to the default method's challenge action – instead of calling success!.
  3. The challenge action lives on Devise::TwoFactorController. Devise auto-generates a new_<method> action per registered method, which renders the method's challenge view.
  4. The user submits the challenge. All methods POST to a single create action, and Warden picks the right strategy via each strategy's valid?. The base Devise::Strategies::TwoFactor then restores remember_me, cleans up the session, and signs the user in.
  5. If verification fails, the strategy calls fail! and Warden recalls back to the challenge action.

PasswordsController#update is also intercepted: when a user with 2FA enabled completes a password reset, they're redirected to the 2FA challenge instead of being signed in directly.

Generic URL helpers (new_two_factor_challenge_path(scope, method), two_factor_path(scope)) and a two_factor_method_links view helper round out the public surface so extensions don't need to share a layout or hardcode route names – they just render their own challenge view and call the helper to render switch links to the user's other enabled methods.

Conventions

For the pieces above to wire up automatically, extensions follow a small set of naming conventions keyed off the short name passed to register_two_factor_method (e.g. :webauthn):

  • <method>_two_factor_enabled? – instance method on the model concern. Returns true when this user has the method configured (e.g. webauthn_two_factor_enabled? returns true when the user has at least one registered credential). Devise uses this to decide whether a user has 2FA on at all, and which methods are currently available for a given user.

    module Devise::Models::WebauthnTwoFactor
      extend ActiveSupport::Concern
    
      included do
        has_many :webauthn_credentials
      end
    
      def webauthn_two_factor_enabled?
        webauthn_credentials.any?
      end
    end
  • new_<method> – controller action. Auto-generated by Devise as an empty action that renders new_<method>.html.erb. Extensions can override it (see "Customizing the challenge action" below). A set_authenticating_resource before_action exposes the resource being challenged as @authenticating_resource for use in challenge views and custom actions.

  • app/views/devise/two_factor/new_<method>.html.erb – challenge view, shipped by the extension gem and resolved through Rails engine view lookup (apps can override by placing a file at the same path).

  • app/views/devise/two_factor/_<method>_link.html.erb – switch-link partial, rendered by the two_factor_method_links helper when a user has multiple methods enabled so they can jump to an alternate factor from any challenge page.

Advanced: customizing the challenge action

Simple methods (e.g. OTP code entry) work out of the box with the auto-generated new_<method> action – no extra wiring needed. Methods that require setup logic on every render (e.g. WebAuthn generating a fresh challenge nonce and stashing it in the session) can inject a custom action via an ActiveSupport.on_load hook:

# In the extension's engine
ActiveSupport.on_load(:devise_two_factor_controller) do
  include Devise::WebauthnTwoFactor::ChallengeAction
end
module Devise::WebauthnTwoFactor::ChallengeAction
  extend ActiveSupport::Concern

  def new_webauthn
    @challenge_options = WebAuthn::Credential.options_for_get(
      allow: @authenticating_resource.webauthn_credentials.pluck(:external_id)
  )
    session[:webauthn_challenge] = @challenge_options.challenge
    render :new_webauthn
  end
end

The auto-generated action is only defined when the extension hasn't already provided one, so the injected method takes precedence.

Validating the interface

In order to test this, I adapted two real 2FA gems on top of this PR — devise-webauthn and devise-otp — and wired them up together in our demo app for devise-webauthn, so that a single application offers users a choice between WebAuthn and TOTP.

Both gems plug into the new interface via Devise.register_two_factor_method without needing to touch Devise's password authentication logic, and the demo app shows them coexisting – the user can pick either factor at the challenge step using the links generated by the two_factor_method_links helper.

What's intentionally out of scope

  • Hooks for extension-specific cleanup (e.g. after_two_factor_verification)
  • Failed-attempt tracking / lockout for 2FA – Lockable is untouched, and failed 2FA attempts don't increment failed_attempts. Rate limiting for 2FA is better handled at the application or middleware level.
  • Re-authentication challenge for sensitive actions.

I hope this lands as a solid foundation that we can build these features on top of.

A note on the scope of this PR

I'm aware this PR is on the ambitious side for a single change but fell into the "why does every 2FA gem reinvent this?" rabbit hole while building devise-webauthn, and had a lot of fun working through what a shared interface might look like – so here we are. Happy to split into smaller PRs if reviewers would prefer that!

A note on AI tooling

Claude Code was used to generate parts of the implementation. I was deeply involved in the design throughout – every architectural decision is something I worked through and chose deliberately. I've also reviewed and edited the generated code before submitting this PR.

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.

Add a standardized two-factor authentication interface for connecting devise extensions that implement 2FA methods

1 participant