Closed TomasBarry closed 3 years ago
Hey @TomasBarry! Did you set change_headers_on_each_request to true in the dta initializer?
@mcelicalderon, yes. What I find strange too is that before the second request, the response headers look like:
response.headers
# { "access-token"=>" ", "token-type"=>"Bearer", "client"=>"SOME_ID", "expiry"=>" ", "uid"=>"SOME_EMAIL" }
See how the access-token
value is just a blank space (as is the expiry
)?
However, after the second request the access-token
and expiry
do have values that are different to the auth_headers
but a third request with the old headers (in auth_headers
) still succeeds (even after the batch_request_buffer_throttle
time).
Hey @TomasBarry, thank you for looking into this. I think I might know where the problem is, but until I get to look into it, could you please share how your graphql_controller is setup? Knowing how you are authenticating the user would be very helpful as there are currently a couple of ways to do it, some of them already deprecated. Also, if you are mounting the gem on your schema via the plugin only or if you might also be using the mount method in your routes file. Thanks!
Sure thing @mcelicalderon.
Here is our x_schema.rb
file:
class XSchema < GraphQL::Schema
# github.com/graphql-devise/graphql_devise#mounting-operations-into-your-own-schema
use(GraphqlDevise::SchemaPlugin.new(
query: Types::QueryType,
mutation: Types::MutationType,
resource_loaders: [
GraphqlDevise::ResourceLoader.new(User, {
skip: [
:sign_up,
:update_password,
:send_password_reset,
:resend_confirmation,
:confirm_account,
:check_password_token,
:confirm_registration_with_token
],
authenticatable_type: Types::Model::UserType
})
],
authenticate_default: true,
public_introspection: true
))
mutation(Types::MutationType)
query(Types::QueryType)
use(BatchLoader::GraphQL)
use(GraphQL::Tracing::ScoutTracing, set_transaction_name: true)
end
Here is our controller:
class GraphqlController < ApplicationController
include GraphqlDevise::Concerns::SetUserByToken
# If accessing from outside this domain, nullify the session
# This allows for outside API access while preventing CSRF attacks,
# but you'll have to authenticate your user separately
protect_from_forgery with: :null_session
def execute
result = ButternutBoxSchema.execute(
query,
variables: variables,
context: context,
operation_name: operation_name
)
render(json: result) unless performed?
rescue => e
raise e unless Rails.env.development?
handle_error_in_development(e)
end
private
def variables
ensure_hash(params[:variables])
end
def query
params[:query]
end
def operation_name
params[:operationName]
end
def context
{
**gql_devise_context(User)
}
end
# Handle form data, JSON body, or a blank value
def ensure_hash(ambiguous_param) # rubocop:disable Metrics/MethodLength
case ambiguous_param
when String
if ambiguous_param.present?
ensure_hash(JSON.parse(ambiguous_param))
else
{}
end
when Hash, ActionController::Parameters
ambiguous_param
when nil
{}
else
raise ArgumentError, "Unexpected parameter: #{ambiguous_param}"
end
end
def handle_error_in_development(error)
logger.error(error.message)
logger.error(error.backtrace.join("\n"))
error = { message: error.message, backtrace: error.backtrace }
render(
json: {
error: error,
data: {}
},
status: :internal_server_error
)
end
end
We are mounting the plugin through the schema only.
My understanding was that using gql_devise_context
to authenticate the user was the suggested and non-deprecated approach and then in context[:current_resource]
you have the authenticated resource, in this case, our user. That is correct, right?
To clarify, this problem only appears to be present in the test environment though.
Is there anything problematic with what you see above?
@TomasBarry thank you for providing details on your implementation. I still haven't been able to take a closer look at this, but I'm pretty sure of what I might be (specially if you are not seeing this problem in production). A workaround for problems like this was discussed in https://github.com/graphql-devise/graphql_devise/issues/151#issuecomment-780080312
please take a look and that will probably work while I work on the real fix. It's probably related to the fact that the schema plugin won't mount the resource in Devise before the first request is made. We fixed that for authentication but I guess I overlooked token regeneration.
@mcelicalderon, so it's just an issue in a test environment?
That's my guess as I haven't been able to take a closer look yet. So, you could try enabling eager load in your test environment and see if that fixes the problem for now.
@mcelicalderon:
Changed to eager loading in test and the issue is still there. Even though when I look at the users token they are being updated it seems that old tokens are still considered valid. A bit unsure as to why this would be the case. Here's a bit of a log of what's happening:
user = create(:user)
user.tokens
# {}
auth_headers = user.create_new_auth_token
# {"CLIENT_ID_1"=>{"token"=>"TOKEN_1", "expiry"=>1626786670, "updated_at"=>"2021-07-06T13:11:10Z"}}
post(:execute, params: { query: query_string })
response.headers
# {"access-token"=>"SOME_TOKEN", "token-type"=>"Bearer", "client"=>"CLIENT_ID_1", "expiry"=>"1626786748", "uid"=>"EMAIL"}
user.reload
user.tokens
# {"CLIENT_ID_1"=>{"token"=>"TOKEN_2", "expiry"=>1626786748, "last_token"=>"TOKEN_1", "updated_at"=>"2021-07-06T13:12:28Z"}}
So the user.tokens
is being updated. But making a request with an old access token is still authenticated when it shouldn't be. Notably, if I Timecop
until after expiry, the token is considered invalid. This seems to just be a problem with rejecting tokens that have already been used (and I do have config.change_headers_on_each_request = true
already set)
So, after enabling eager load is the problem with the first request gone? I mean an empty token on the response.
For the other token remaining valid, how soon after the first request are you making the next one? There's a configuration in devise token auth that will allow the token to remain valid for a certain amount of time so you are able to batch requests. I think the default might be 5 seconds before it becomes invalid. https://github.com/graphql-devise/graphql_devise/blob/a69b27a402933861304ebb35707b130092d15b4f/spec/dummy/config/initializers/devise_token_auth.rb#L27
@mcelicalderon, regarding batch_request_buffer_throttle
. I'm waiting until after this throttle time. I've tried both sleeping for batch_request_buffer_throttle + 1.second
and Timecopping to after that buffer time. That's what's confusing me, in production this behaves as expected but in test I can't seem to get the error on token re-use (after the throttle time).
@mcelicalderon any further ideas on this one?
Hey @TomasBarry, sorry we haven't been able to look into this yet. I'll try to look into this over the weekend. No more ideas of the top of my head. I can't think of another reason why it could behave differently in dev/test other than the eager load setting.
If you could provide a small sample project where the errors is replicated that would be ideal so I can test the same conditions you are
Hi @TomasBarry, validating wether or not a token is valid is handled by DTA at the moment, I think you may find the answer to this unexpected behavior checking valid_token?, token_is_current? and token_can_be_reused?.
Another change that I think can help your tests is saving the user instance (.save) inside of the timecop/travel block so that destroy_expired_tokens is triggered.
Please let us know what happen when you tap into into this methods.
@00dav00, valid_token?
actually returns true
which seems to be the issue.
Going line by line, user.token_is_current?
returns true
even though it's already been used.
Hey @TomasBarry, I was finally able to look into this and found the issue. It looks like DTA uses updated_at
on the created token to identify if a request is a batch request. Related code here. For this reason, if you create a new token and don't wait at least as many seconds as the throttle value before making the first request, DTA will consider the first request a batch request and then not behave as expected.
I don't think this is something we should fix in this gem, but I'll keep looking a bit more into it. What you can do now as a workaround is making sure you wait at least as much as the configured throttle time between creating a new token and making the first request. Then of course you will also have to sleep
or use time_travel to wait enough for the second request. This piece of code illustrates how to make it fail if throttle is configured for 1.second:
context 'when authenticating before using the GQL schema' do
before do
headers
sleep 5
post_request('/api/v1/controller_auth')
end
context 'when user is authenticated' do
let(:e_user) { create(:schema_user) }
let(:headers) { e_user.create_new_auth_token }
it 'allows authentication at the controller level' do
sleep 5
# This request will fail
post_request('/api/v1/controller_auth')
end
end
end
Update: Let me know if that worked and I think we can close this issue. Not sure if there's a better way to do what DTA is currently doing, but in any case, that's something that should be fixed over there. Now that I think about it, it should probably be fixed as for whatever reason, you could use your login token immediately after getting it in a production app and then getting this erroneous behavior.
@mcelicalderon, that makes sense. However, I can't replicate your failure:
auth_headers = user.create_new_auth_token
sleep(DeviseTokenAuth.batch_request_buffer_throttle + 1.second)
request.headers.merge!(auth_headers)
post(:execute, params: { query: query_string })
sleep(DeviseTokenAuth.batch_request_buffer_throttle + 1.second)
post(:execute, params: { query: query_string })
The same headers are used in both queries but the second query still succeeds. The same is true with Timecop. Do you have an example repo/branch where I can see your example working and I'll debug from there?
Sure, @TomasBarry here's a branch where I changed a single spec. Others will fail as we were not testing with tokens valid for a single request setting enabled. But take a look at the commit and you'll see the expectation works. Also, here's my Gemfile.lock so you can check if it's related to a version of a dependency.
branch commit (for future reference)
Yes, @TomasBarry that one is definitively passing (I mean the first request, you can inspect that). I changed an existing spec to test this, didn't give it much thought as we are not keeping the spec like that. I would advise using a gem like pry to debug further. Our helper method post_request
will use whatever you have defined as headers
on every request you make via post_request
@mcelicalderon, nice. Playing with that commit it looks fine. Will keep digging in on my side I imagine it's something on the setup we're doing.
Thanks for digging into this.
No problem, @TomasBarry. I'm closing this one as that appears to be the problem and the issue should be moved to DTA. Feel free to reopen if you run into something else. I might open an issue on DTA if you don't.
Describe the bug
When using the same token in two requests in a spec file the second request accepts the then expired token.
Environment
Steps to reproduce
Using
Timecop
results in the same issue.Expected behavior
I would expect the second request to return an error.
Actual behavior
The second request returns the requested data.