Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for sending and receiving the auth token via a server cookie #1453

Merged
merged 8 commits into from
Mar 11, 2021
Merged

Conversation

theblang
Copy link
Contributor

@theblang theblang commented Jan 5, 2021

There are two main reasons to use a server cookie to send and receive the auth token.

  1. For sharing auth state across subdomains with the Domain attribute. From the MDN docs:

The Domain attribute specifies which hosts are allowed to receive the cookie. If unspecified, it defaults to the same origin that set the cookie, excluding subdomains. If Domain is specified, then subdomains are always included. Therefore, specifying Domain is less restrictive than omitting it. However, it can be helpful when subdomains need to share information about a user.

Note that you cannot access localStorage across subdomains.

  1. For security reasons. If you only send the auth token in an HttpOnly server cookie then JavaScript can never access it, which helps protect against XSS attacks. These three issues are mostly pertaining to the security aspect: Where to store token securely? #1281 Storing auth token in browser best practise #1321 Does DTA support HTTP Only Cookie Refresh tokens along site access tokens? #1371

This PR introduces a server cookie as an additive opt-in feature to send and receive the auth token, which will allow for the subdomain sharing mentioned in point one. By introducing the option to use a server cookie, we lay the groundwork for addressing the security aspect in point two. I didn't want to make the changes for point two yet as that will be more impactful, because we'd need to make it possible to not expose the auth token in headers. I imagine if and when we do that work we'll probably add an analogous headers_enabled config to hinge off of in code to turn off sending and receiving the auth token in headers.

cc @booleanbetrayal @nbrustein @apuntovanini

@@ -55,6 +55,16 @@ def destroy
user.tokens.delete(client)
user.save!

if DeviseTokenAuth.cookie_enabled
if DeviseTokenAuth.cookie_attributes[:domain]
Copy link
Contributor Author

@theblang theblang Jan 5, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized that we might need to be smarter about determining the domain attribute. We might need to determine it by looking at the request's domain (in which case we'd probably want to allow configuring a whitelist, like we do with redirect_whitelist). This is because the Domain cookie attribute only allows one value, so as the code sits now you wouldn't be able to handle multiple domains being served from the same box.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sense, remember to use allowlist instead of whitelist

Copy link
Contributor Author

@theblang theblang Jan 5, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@apuntovanini Another option is to just support passing to the cookie_attributes[:domain] config either a string, which will work for most people, or a proc / lambda, which could accept the request and do whatever logic (in our case, return the domain of the request so long as it matches the allow list).

Copy link
Contributor Author

@theblang theblang Jan 6, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are the changes for that second option I mentioned. And our project's config looks like:

    config.cookie_attributes = {
        domain: lambda { |request| request.domain if valid_cookie_domains.include?(request.domain) },
        secure: Rails.env.production?,
        httponly: true,
        same_site: 'Strict',
        expires: token_lifespan.from_now,
        encrypt: true
    }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤦 I just saw this in the in the Rails docs:

To support multiple domains, provide an array, and the first domain matching request.host will be used. Make sure to specify the :domain option with :all or Array again when deleting cookies.

So I can totally gut this Proc logic now, and instead just pass along either a string or Array from the config to the cookie call.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See this commit. So much cleaner! I wish I had realized that earlier, lol.

@apuntovanini
Copy link

I tested in our application and works like charm. I only have issues logging out (we use a graphql endpoint to make our calls, we should probably switch to classic controllers flow for authentication instead). But sharing cookies with our subdomains is crucial to us, well done!

@theblang
Copy link
Contributor Author

theblang commented Jan 5, 2021

I tested in our application and works like charm. I only have issues logging out (we use a graphql endpoint to make our calls, we should probably switch to classic controllers flow for authentication instead). But sharing cookies with our subdomains is crucial to us, well done!

Yay, thanks so much! I'm so glad this will help y'all as well!

elsif config_value.is_a?(Proc)
config_value.call(request)
end
end
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@@ -9,6 +9,7 @@ test/dummy/.sass-cache
test/dummy/config/application.yml
coverage
.idea
.byebug_history
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drive-by that popped up while debugging a test

Copy link
Collaborator

@MaicolBen MaicolBen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like using cookies in APIs but your reasoning of why is a good alternative sounds good

if cookie_domain.present?
# If a cookie is set with a domain specified then it must be deleted with that domain specified
# See https://stackoverflow.com/a/6244724/1747491
cookies.delete(DeviseTokenAuth.cookie_name, domain: cookie_domain)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is unreached by specs but it could be ok

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conditional will be moot now, see this recent commit.


cookie_domain = get_cookie_domain
if cookie_domain.present?
cookie_attributes.merge!(domain: cookie_domain)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is unreached by specs but it could be ok

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conditional will be moot now, see this recent commit.

# more flexibility, such as to dynamically choose the domain when serving multiple domains
# from a single server, you can set a Proc that will be passed the request.
config_value = DeviseTokenAuth.cookie_attributes[:domain]
if config_value.is_a?(String)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These cases are unreached in specs but that could be ok

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moot now that I've gutted the unnecessary Proc logic.

@@ -35,11 +35,20 @@ def set_user_by_token(mapping = nil)
access_token_name = DeviseTokenAuth.headers_names[:'access-token']
client_name = DeviseTokenAuth.headers_names[:'client']

# gets values from cookie if configured and present
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can all this new cookie code be in a new module like set_user_by_cookie or something like that, so we don't pollute more this big file, you can include the concern/module in this file

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can, my only push back would be that hopefully in the future we'll allow turning off headers (for the security reasons mentioned in point two of the description). In that case, it might feel a bit disjointed to have the header logic in set_user_by_token while the cookie logic is in set_user_by_cookie, when really both logics deal with handling the token. In that future world, would we move the header logic to another concern called set_user_by_headers? If so, wouldn't set_user_by_token just become a skeleton file?

Copy link
Contributor Author

@theblang theblang Jan 7, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, see my recent realization. That may clean up the amount of noise in this file considerably.

@apuntovanini
Copy link

I don't like using cookies in APIs but your reasoning of why is a good alternative sounds good

Just to understand, how would how tackle cross-subdomain authentication with api without a cookie? Thanks!

…ty with setting domain to the Proc if the Proc returns nil. clean up unncessary present conditionals.
@theblang
Copy link
Contributor Author

theblang commented Jan 7, 2021

I don't like using cookies in APIs but your reasoning of why is a good alternative sounds good

Yeah, we weren't too keen on it either. But it's the only way to share state across subdomains. And the general consensus seems to be that it's more secure for auth (once you're using only the HttpOnly cookie, which we'll hopefully allow in later work).

@theblang theblang requested a review from MaicolBen January 7, 2021 20:25
@theblang
Copy link
Contributor Author

theblang commented Jan 17, 2021

@MaicolBen @booleanbetrayal Could one of you retry the failed Travis job? It seems that just one of the jobs failed with:

Fetching nokogiri 1.10.10
Bundler::GemspecError: Could not read gem at
/home/travis/build/lynndylanhurley/devise_token_auth/gemfiles/vendor/bundle/ruby/2.3.0/cache/nokogiri-1.10.10.gem.
It may be corrupted.
An error occurred while installing nokogiri (1.10.10), and Bundler
cannot continue.
Make sure that `gem install nokogiri -v '1.10.10' --source
'https://rubygems.org/'` succeeds before bundling.
In rails_5_1.gemfile:
  devise_token_auth was resolved to 1.1.4, which depends on
    rails was resolved to 5.2.4.4, which depends on
      actioncable was resolved to 5.2.4.4, which depends on
        actionpack was resolved to 5.2.4.4, which depends on
          actionview was resolved to 5.2.4.4, which depends on
            rails-dom-testing was resolved to 2.0.3, which depends on
              nokogiri

@booleanbetrayal
Copy link
Collaborator

@theblang - running this now produces a new error:

You are using an old OmniAuth version, please ensure you have 1.0.0.pr2 version or later installed.

It looks like Devise has hard-coded the omniauth dependency in a way that it's breaking it's 2.0.0 release. It would be preferable if they fixed this on their end, but we might want to constrain the Gem version in the project if they are slow with a release. (cc: @MaicolBen )

@MaicolBen
Copy link
Collaborator

I'm going to wait for heartcombo/devise#5326 for a few days

@theblang
Copy link
Contributor Author

theblang commented Feb 1, 2021

Looks like we're close!

@MaicolBen
Copy link
Collaborator

Can you rebase/update with last master?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants