I’ve had to use Facebook login inside an iframe twice now. Combine Devise
with omniauth and omniauth-facebook and you have a
pretty great user authentication system. Now try to login via Facebook inside an iframe and nothing will happen. If you check your Javascript console you’ll see a error
that looks something like this:
Refused to display ‘https://www.facebook.com/login.php?skip_api_login=1&api_key=asdfasdf%23_%3D_&display=page’ in a frame because it set ‘X-Frame-Options’ to ‘DENY’.
Facebook won’t render their UI inside of an iframe. A lot of sites do that these days. However, you can use the Facebook Javascript client and everything will
just work – sort of. Omniauth-facebook documents a Client side flow which looks like the
perfect solution to our problem but as I’ve learned it’s not rainbows and unicorns.
If you do follow the advice provided by the documentation you’ll spend hours saying things to your computer that you will regret. You’ll feel depressed because you
followed the documentation and yet request.env[“omniauth.auth”] will be nil no mater what you do.
There are two github threads related to this problem: https://github.com/mkdynamic/omniauth-facebook/issues/73
and https://github.com/intridea/omniauth-oauth2/issues/31 and a number of Stack Overflow questions
including this one that I tried to answer since way
more people will find it on Stack Overflow than here.
Some Tips Before We Step In the Deep Stuff
If you’re debugging through this problem the first thing you might try is using a domain like lvh.me to access your local machine (it resolves to 127.0.0.1). Chrome
has issues writing to localhost and it’s possible that the Facebook cookie you need is not being properly written.
Right now would be a good time to check to make sure you don’t accidently do the Oauth dance twice. This bit me. We have a link on the page with the id ‘facebook_connect’.
It just so happens that the href of that link is ‘/users/auth/facebook’ which means it will initiate the Oauth dance using Omniauth. We only want to talk to Facebook once
so be sure to call e.preventDefault() or else you will keep wondering why you get two server calls:
$('#facebook_connect').on('click', function(e){ e.preventDefault(); // Stop the request right here. Facebook.login(); }); </pre>
The next thing you'll want to verify is that you are telling Facebook to write a cookie.FB.init({ appId : GLOBAL_SETTINGS.FBappId, status : false, // don't check login status cookie : true, // enable cookies to allow the server to access the session xfbml : true // parse XFBML }); </pre>
Before We Start
If you just want to see how to do the Facebook OAuth dance client side below is the code I use to handle Facebook logins:The Problem
I'm guessing you're still running into problems. The source of the issue is the callback_phase method inside the omniauth-oauth2 gem:if !options.provider_ignores_state && (request.params['state'].to_s.empty? || request.params['state'] != session.delete('omniauth.state')) raise CallbackError.new(nil, :csrf_detected) end </pre>
request.params['state'] and session['omniauth.state'] are both nil so the condition fails and a CallbackError exception is raised. This is due to the fact that we initiated the Facebook OAuth dance via FB.Login rather than using Omniauth to initiate the dance. Omniauth sets a state variable in the session and then passes that as a state variable to Facebook like this:session['omniauth.state'] = SecureRandom.hex(24) </pre>
You can see above that the omniauth-oauth2 gem checks to make sure the state passed back from Facebook matches the one it saved into the session before the dance started. Sucks for the client side process.Solution 1 - Cheap and Easy but Not So Secure
One solution is to set provider_ignores_state to true which circumvents the condition:config.omniauth :facebook, ENV['FACEBOOK_APP_ID'], ENV['FACEBOOK_APP_SECRET'], { strategy_class: OmniAuth::Strategies::Facebook, provider_ignores_state: true, } </pre>
That solution isn't especially secure since it can leave you open to csrf attacks.Solution 2 - More Code Solves Everything
More code isn't usually a great way to solve your problems, but you can always create your own handler and parse the Facebook cookies yourself like this:def handle_facebook_connect @provider = 'facebook' @oauth = Koala::Facebook::OAuth.new(ENV["FACEBOOK_ID"], ENV["FACEBOOK_SECRET"]) auth = @oauth.get_user_info_from_cookies(cookies) # Get an extended access token new_auth = @oauth.exchange_access_token_info(auth['access_token']) auth['access_token'] = new_auth["access_token"] # Use the auth object to setup or recover your user. The following is # and example of how you might handle the response but your needs and application structure will vary. if authentication = Authentication.where(:uid => auth['user_id'], :provider => @provider).first user = authentication.user sign_in(user, :event => :authentication) end # Redirect or respond with json respond_to do |format| format.html { redirect_to user } format.json { render json: user } end end </pre>
Then you'll need to redirect to the 'handle_facebook_connect' method when you receive a connected response:FB.Event.subscribe('auth.authResponseChange', function(response) { if(response.status === 'connected'){ if(response.authResponse){ // Redirect to our new handler window.location = '/handle_facebook_connect'; } } else if (response.status === 'not_authorized'){ Facebook.message(Facebook.authorize_message); } else { FB.login(); } }); </pre>
Solution 3 - Fake It
If nothing so far brings joy to your heart then we can also simulate what omniauth does with the state variable. I create a helper method that can be called where ever we need to use the client side Facebook login. We also have a global settings object that can be accessed by our Javascript on the client. Calling 'add_state' generates a secure value and passes it to the client.def global_settings settings = { FBappId: ENV["FACEBOOK_ID"], application_name: ENV["APPLICATION_NAME"] } settings[:state] = session['omniauth.state'] = @add_state if @add_state settings end def add_state @add_state ||= SecureRandom.hex(24) end </pre>
Then have a look at the finish function in the javascript. Here we pass the state from GLOBAL_SETTINGS when we call '/users/auth/facebook/callback':finish: function(response){ window.location.href = '/users/auth/facebook/callback?state='+ GLOBAL_SETTINGS.state; } </pre>
Ideally, I would pass the state value when I call FB.Login but as far as I can tell from the Facebook documentation they don't provide a mechanism for passing parameters. It is possible to manually create the FB login popup in which case it would be possible to pass the state, but this solution was sufficient for our needs.