Short version
When using dynamic route segments with OmniAuth the official recommendation of Devise is to use multiple calls of devise_for: (Source)
devise_for :users, only: :omniauth_callbacks, controllers: { omniauth_callbacks: 'users/omniauth_callbacks' }
scope '/(:locale)', locale: /ru|en/ do
devise_for :users, skip: :omniauth_callbacks
end
But for me this simply doesn't work, because the settings from the first devise_for call are lost (i.e. the overridden controller is gone) and only the configuration from the second call is kept.
What am I missing here?
In my opinion it cannot work ever, because each and every devise_for call replaces the previous mapping and all of its settings.
On the other hand it is the officially documented way and there is some praise about this solution in the Devise issue tracker.
More details
Devise source code
This is how the code looks like: (Source)
def devise_for(*resources)
# ...
resources.each do |resource|
mapping = Devise.add_mapping(resource, options)
# ...
end
#...
end
And the code for add_mapping: (Source)
def self.add_mapping(resource, options)
mapping = Devise::Mapping.new(resource, options)
@@mappings[mapping.name] = mapping
# ...
end
As you can see any previous mapping and its configuration is replaced with the new one. According to git blame this code hasn't changed in the last 11 years.
Debugging
I used a debugger on the code given in the Devise Wiki: (Source)
devise_for :users, only: :omniauth_callbacks, controllers: { omniauth_callbacks: 'auth/oauth_callbacks' }
scope '(:locale)' do
devise_for :users, skip: :omniauth_callbacks,
controllers: { passwords: 'auth/passwords', registrations: 'auth/registrations' }
end
After the first devise_for call the controller configuration in the Devise mappings looked like this:
Devise.mappings.first[1].controllers = Hash (1 element)
:omniauth_callbacks => "auth/oauth_callbacks"
After the second call:
Devise.mappings.first[1].controllers = Hash (5 elements)
:passwords => "auth/passwords"
:registrations => "auth/registrations"
:sessions => "devise/sessions"
:confirmations => "devise/confirmations"
:unlocks => "devise/unlocks"
As you can see, the original controller configuration is gone.
Devise Wiki, Issues
The following Wiki articles, issues and comments say that using multiple devise_for calls is in fact working:
- https://github.com/heartcombo/devise/wiki/How-To:-OmniAuth-inside-localized-scope
- https://github.com/heartcombo/devise/issues/3651#issue-90422948
- https://github.com/heartcombo/devise/pull/2227#issuecomment-31399288
I'm seriously doubting myself here: On one hand this is the official way to go when using OmniAuth with dynamic segments, accompanied by various confirmations by other users. On the other hand it's obvious (imho) that this just cannot work.
Am I missing something here?
EDIT:
After further investigation I'm more and more sure this never actually worked in Devise. The reason nobody noticed is that when accessing a missing controller configuration, Devise auto-generates a default configuration (re-generates in this case)
In Devise::Mapping#default_controllers: (Source)
def default_controllers(options)
mod = options[:module] || "devise"
@controllers = Hash.new { |h,k| h[k] = "#{mod}/#{k}" } # <----------- DEFAULT
@controllers.merge!(options[:controllers]) if options[:controllers]
@controllers.each { |k,v| @controllers[k] = v.to_s }
end
So in case someone only wants to just pull up the routes omniauth module out of the dynamic route segment, the documented way actually does work, but this is more or less by chance. The first call to devise_for still cannot be omitted because it generates routes and helper methods (which are maintained by Rails and maintained over multiple calls). But the controllers: ... option could just as well be omitted and the auto-generated configuration would kick in.
So if you want the integrated omniauth controller of Devise the official solution kinda works, if you actually need a different controller, it doesn't.
EDIT 2:
Adding this line after the second devise_for call restores the controller configuration. With it the recommended solution actually works:
Devise.mappings[:user].controllers[:omniauth_callbacks] = 'users/omniauth_callbacks'
I added an issue to the Devise issue tracker, this really seems to be a bug or at least an unexpected edge case. (Issue)