Skip to content

Commit c4069b0

Browse files
committed
Persist the 'state' parameter until a successful callback.
We discovered the following issue while using [omniauth-facebook][1]. We are seeing an unexpectedly high number of CSRF violations (roughly 6% of sign-ins) when users return from facebook.com. Every time the user visits `/auth/facebook` and invokes the `request_phase` method, a new `state` value is generated and put in the session. If a user opens the site in two tabs and navigates to `/auth/facebook` in each, then they have two copies of https://www.facebook.com/dialog/oauth open, each with a different `state` param. But, only one of these state params is in the session under the `omniauth.state` key. One of these pages will thus have a `state` param that does not match the session. It is also possible to trigger this with concurrent or duplicate requests, or by using the back/forward buttons, causing a race condition in updating the session. The page with the `state` value that "lost" the race will authorize Facebook's permissions, but then fail when redirected to `/auth/facebook/callback` due to the token mismatch. I wondered why this gem always assigns a new `state` value on visiting `/auth/:provider`, compared to how [rack-protection][2] and [Rails][3] implement CSRF protection using a guarded assignment. My assumption in this patch is that this is to avoid sending a value to one provider that could be redeemed by another. If I send a state value to alice.com, then someone working for alice.com could take that value and the account of the user that authenticated there, then email the user a phishing callback link for bob.com, including a `state` value that will work. To avoid this, I have introduced a namespace into the session: each `state` value is keyed by the class name of the particular provider, so you can have a different state value per provider, but have them be reusable. The value is still removed from the session on completion to avoid replay attacks. Keeping the `state` constant until the auth process completes means it's safe to open multiple tabs and perform concurrent requests, or anything else that might leave the session in an inconsistent or unpredictable state. [1]: https://github.com/mkdynamic/omniauth-facebook [2]: https://github.com/sinatra/rack-protection/blob/v1.5.3/lib/rack/protection/authenticity_token.rb#L24 [3]: https://github.com/rails/rails/blob/v4.2.3/actionpack/lib/action_controller/metal/request_forgery_protection.rb#L316
1 parent 8f3b9e3 commit c4069b0

File tree

2 files changed

+71
-14
lines changed

2 files changed

+71
-14
lines changed

lib/omniauth/strategies/oauth2.rb

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,10 @@ def request_phase
5353
end
5454

5555
def authorize_params
56-
options.authorize_params[:state] = SecureRandom.hex(24)
57-
params = options.authorize_params.merge(options_for("authorize"))
58-
if OmniAuth.config.test_mode
59-
@env ||= {}
60-
@env["rack.session"] ||= {}
61-
end
62-
session["omniauth.state"] = params[:state]
63-
params
56+
site = self.class.name
57+
state = state_store.fetch(site) { state_store[site] = SecureRandom.hex(24) }
58+
options.authorize_params[:state] = state
59+
options.authorize_params.merge(options_for("authorize"))
6460
end
6561

6662
def token_params
@@ -69,11 +65,18 @@ def token_params
6965

7066
def callback_phase # rubocop:disable AbcSize, CyclomaticComplexity, MethodLength, PerceivedComplexity
7167
error = request.params["error_reason"] || request.params["error"]
68+
site = self.class.name
69+
expected_state = state_store[site]
70+
actual_state = request.params["state"].to_s
71+
7272
if error
73-
fail!(error, CallbackError.new(request.params["error"], request.params["error_description"] || request.params["error_reason"], request.params["error_uri"]))
74-
elsif !options.provider_ignores_state && (request.params["state"].to_s.empty? || request.params["state"] != session.delete("omniauth.state"))
73+
description = request.params["error_description"] || request.params["error_reason"]
74+
error_uri = request.params["error_uri"]
75+
fail!(error, CallbackError.new(request.params["error"], description, error_uri))
76+
elsif !options.provider_ignores_state && (actual_state.empty? || actual_state != expected_state)
7577
fail!(:csrf_detected, CallbackError.new(:csrf_detected, "CSRF detected"))
7678
else
79+
state_store.delete(site)
7780
self.access_token = build_access_token
7881
self.access_token = access_token.refresh! if access_token.expired?
7982
super
@@ -109,6 +112,22 @@ def options_for(option)
109112
hash
110113
end
111114

115+
private
116+
117+
def state_store
118+
if OmniAuth.config.test_mode
119+
@env ||= {}
120+
@env["rack.session"] ||= {}
121+
end
122+
123+
state_key = "omniauth.oauth2.state"
124+
state_store = session[state_key]
125+
unless state_store.is_a?(Hash)
126+
state_store = session[state_key] = {}
127+
end
128+
state_store
129+
end
130+
112131
# An error that is indicated in the OAuth 2.0 callback.
113132
# This could be a `redirect_uri_mismatch` or other
114133
class CallbackError < StandardError

spec/omniauth/strategies/oauth2_spec.rb

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,49 @@ def app
5353
expect(instance.authorize_params["scope"]).to eq("bar")
5454
expect(instance.authorize_params["foo"]).to eq("baz")
5555
end
56+
end
5657

57-
it "includes random state in the authorize params" do
58-
instance = subject.new("abc", "def")
59-
expect(instance.authorize_params.keys).to eq(["state"])
60-
expect(instance.session["omniauth.state"]).not_to be_empty
58+
describe "state handling" do
59+
SocialNetwork = Class.new(OmniAuth::Strategies::OAuth2)
60+
61+
let(:client_options) { {:site => "https://graph.example.com"} }
62+
let(:instance) { SocialNetwork.new(-> env {}) }
63+
64+
before do
65+
allow(SecureRandom).to receive(:hex).with(24).and_return("hex-1", "hex-2")
66+
end
67+
68+
it "includes a state scoped to the client" do
69+
expect(instance.authorize_params["state"]).to eq("hex-1")
70+
expect(instance.session["omniauth.oauth2.state"]).to eq("SocialNetwork" => "hex-1")
71+
end
72+
73+
context "once a state value has been generated" do
74+
before do
75+
instance.authorize_params
76+
end
77+
78+
it "does not replace an existing session value" do
79+
expect(instance.authorize_params["state"]).to eq("hex-1")
80+
expect(instance.session["omniauth.oauth2.state"]).to eq("SocialNetwork" => "hex-1")
81+
end
82+
end
83+
84+
context "on a successful callback" do
85+
let(:request) { double("Request", :params => {"code" => "auth-code", "state" => "hex-1"}) }
86+
let(:access_token) { double("AccessToken", :expired? => false, :expires? => false, :token => "access-token") }
87+
88+
before do
89+
allow(instance).to receive(:request).and_return(request)
90+
allow(instance).to receive(:build_access_token).and_return(access_token)
91+
92+
instance.authorize_params
93+
instance.callback_phase
94+
end
95+
96+
it "removes the value from the session" do
97+
expect(instance.session["omniauth.oauth2.state"]).to eq({})
98+
end
6199
end
62100
end
63101

0 commit comments

Comments
 (0)