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

Implement JWT callback endpoint #951

Merged
merged 12 commits into from
May 6, 2020
Merged

Implement JWT callback endpoint #951

merged 12 commits into from
May 6, 2020

Conversation

ragalie
Copy link
Contributor

@ragalie ragalie commented Apr 13, 2020

Goals

When we receive an OAuth callback with a JWT in the headers, we want to do two things:

  1. Validate that the omniauth data matches the JWT data.
  2. Persist the token information to the session store.

Implementation

  1. It's a bit of a pain to have two separate callback paths in omniauth. There is a callback_path option, and it does take a block, but I'm a little unclear about how it knows how to start the normal OAuth flow if you use a block for this option. I elected to incorporate the JWT flow into the existing callback path in a backwards-compatible fashion.
  2. Omniauth tries to check the state param as part of the callback action and throws an error if the param doesn't exist or doesn't match the param in the session. We won't have a param in the session in this context. So we need to conditionally disable the state check, based on whether we have a valid JWT. This is safe, I believe, because the goal of the state check is to avoid CSRF, and we can trust the JWT as evidence that the user is doing this from the Shopify context.
  • To make this work, I moved the JWT logic into a Rack middleware. This has two benefits: (1) it means that other Rack middleware (such as omniauth) can make decisions based on the JWT data, and (2) it allows any Rack application to decode our JWT, even if they're not using Rails or the shopify_app controller stuff.

@ragalie ragalie self-assigned this Apr 13, 2020
@ragalie ragalie marked this pull request as ready for review April 15, 2020 16:03
@ragalie ragalie requested a review from a team as a code owner April 15, 2020 16:03
@theundeadmonk
Copy link
Contributor

Is there a reason we prefer not to override the old callback endpoint to handle both cases based on whether enable_jwt_auth is set in the config?

Would that be easier since we then wouldn't have to keep track of both urls.

@ragalie
Copy link
Contributor Author

ragalie commented Apr 15, 2020

I don't think we can do that because the existing callback endpoint has to run for the initial install (to get the shop token), even if allow_jwt_authentication is true.

def jwt_callback
if valid_jwt_auth?
create_user_session_from_jwt_callback
head(:ok)
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we use head(:no_content) instead?

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'd rather stick with OK because I think we're likely to add some content later, and I don't want apps to break when the status code switches from 204 => 200.

@@ -24,8 +24,31 @@ def callback
end
end

def jwt_callback
Copy link
Contributor

Choose a reason for hiding this comment

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

Since this is only for user tokens, should we call this user_callback?

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 think it's more tightly coupled to the jwt aspect than the user aspect, since the other callback endpoint can also handle user tokens.

@ragalie
Copy link
Contributor Author

ragalie commented Apr 20, 2020

Ok, so there are some issues with the current approach :) Here's what I've discovered:

  1. It's a bit of a pain to have two separate callback paths in omniauth. There is a callback_path option, and it does take a block, but I'm a little unclear about how it knows how to start the normal OAuth flow if you use a block for this option. I'll probably switch to using /callback for both (as Adi originally suggested) since I'll be fighting less with omniauth.
  2. Omniauth tries to check the state param as part of the callback action and throws an error if the param doesn't exist or doesn't match the param in the session. We won't have a param in the session in this context. So we need to conditionally disable the state check, based on whether we have a valid JWT. This is safe, I believe, because the goal of the state check is to avoid CSRF, and we can trust the JWT as evidence that the user is doing this from the Shopify context. cc @leleabhinav
  3. We'll need to update the logic in the omniauth gem to support three settings for what kind of tokens are expected: only shop, only user, shop or user. Right now it supports either shop or user, and we use a hacky session thing to work around this, and that isn't viable for our use case. Since it's quite common to use both shop and user tokens, we should just natively support this.

I'll work on this later today and we can see how it looks and if we like it.

@theundeadmonk
Copy link
Contributor

LGTM.
I was wondering if we could perhaps reject the request in the middleware itself if the JWT is invalid. Not sure if we have the Shop or the User info there, but we could check for things like expiry, etc. Just a thought.

@ragalie
Copy link
Contributor Author

ragalie commented Apr 22, 2020

I'm reluctant to do that because some apps might use other bearer tokens in parts of their app. I think rejecting the request is more of a controller concern.

jwt_callback
return
end

Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be if/elsif/else blocks rather than having the early return?

Comment on lines 15 to 23
login_shop

Copy link
Contributor

Choose a reason for hiding this comment

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

Should this block of code be extracted out for single-level-of-abstraction reasons? It looks like we're doing that with jwt_callback above, and it seems nice.

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 tried cleaning this up for clarity, take a look and see what you think!

Copy link
Contributor

@rezaansyed rezaansyed left a comment

Choose a reason for hiding this comment

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

Tophatted it with Adi and works as expected 👍

@ragalie
Copy link
Contributor Author

ragalie commented May 5, 2020

@leleabhinav are you OK with where this ended up?

ragalie added 8 commits May 5, 2020 09:09
This decouples retrieving the JWT data from the Rails controller layer
so that we can use this data when making decisions in the omniauth
middleware.
It's a bit of a pain to have two separate callback paths in omniauth.
There is a callback_path option, and it does take a block, but I'm a
little unclear about how it knows how to start the normal OAuth flow
if you use a block for this option.

To keep things simple I went with the single callback endpoint.
Technically it does more than we need, but we protect against the other
paths elsewhere, and this simplies the logic overall.
@leleabhinav
Copy link

@leleabhinav are you OK with where this ended up?
👍 🚢

@ragalie
Copy link
Contributor Author

ragalie commented May 5, 2020

@Shopify/platform-dev-tools-education I'm ready for a review on this when you have a chance :) Thanks!

Copy link
Contributor

@tanema tanema left a comment

Choose a reason for hiding this comment

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

This looks good and an interesting approach. I wonder if in the future, the middleware could be extracted so that we could support more than just rails.

@ragalie
Copy link
Contributor Author

ragalie commented May 6, 2020

For sure, I'm thinking we could pretty easily make that move if/when we think it would be useful.

@ragalie ragalie merged commit 914f992 into master May 6, 2020
@ragalie ragalie deleted the jwt-callback branch May 6, 2020 16:51
@rezaansyed rezaansyed temporarily deployed to rubygems May 7, 2020 16:16 Inactive
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.

6 participants