waiting-for-dev / devise-jwt

JWT token authentication with devise and rails
MIT License
1.26k stars 130 forks source link

Not getting the JWT token when signing in via a graphql-ruby mutation #254

Closed janosrusiczki closed 1 year ago

janosrusiczki commented 1 year ago

Expected behavior

I would like to get the JWT token when signing in via a GraphQL mutation.

Actual behavior

Not getting the token. 😒

Steps to Reproduce the Problem

I am using graphql-ruby and the mutation's implementation looks like this:

# app/graphql/types/mutation_type.rb
module Types
  class MutationType < GraphQL::Schema::Object
    field :login, UserType, null: true do
      description 'Login'
      argument :email, String, required: true
      argument :password, String, required: true
    end

    def login(email:, password:)
      user = User.find_for_database_authentication(email: email)
      return unless user&.valid_password?(password)
      # sign_in(:user, user)
      user
    end
  end
end

I tried adding sign_in(:user, user) before the user line but I'm getting an "undefined local variable or method `session'" error.

The user model:

# app/models/user.rb
class User < ApplicationRecord
include Devise::JWT::RevocationStrategies::JTIMatcher
include Tokenizable

  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  # :database_authenticatable, :registerable,
  # :recoverable, :rememberable, :validatable
  devise :database_authenticatable,
         :rememberable,
         :jwt_authenticatable, jwt_revocation_strategy: self

  has_many :photos
end

The user model has token defined via the Tokenizable concern:

# app/models/concerns/tokenizable.rb
require 'active_support/concern'

module Tokenizable
  extend ActiveSupport::Concern

  included do
    def token
      token, payload = user_encoder.call(
        self, devise_scope, aud_headers
      )
      token
    end

    private def devise_scope
      @devise_scope ||= Devise::Mapping.find_scope!(self)
    end
  end

  private def user_encoder
    Warden::JWTAuth::UserEncoder.new
  end

  private def aud_headers
    token_headers[Warden::JWTAuth.config.aud_header]
  end

  private def token_headers
    { 
      'Accept' => 'application/json', 
      'Content-Type' => 'application/json' 
    }
  end
end

The problem is that I can't have the main GraphQL controller inherit from the Devise::SessionController, because that does all the other queries and mutations, so from what I understand I should do the sign_in programatically somehow from this mutation, I just don't know how.

Debugging information

janosrusiczki commented 1 year ago

Don't want to be pushy but I would really appreciate any ideas or guidance with this. It seems to me that this gem is the cleanest way towards JWT tokens via Devise in a Rails app. But I want to issue the tokens from a GraphQL mutation. :wink:

waiting-for-dev commented 1 year ago

Yeah, you're not going through the Devise path, so it's probably not going to work. You can use bare ruby-jwt, or leverage somehow warden-jwt_auth. Take a look at how it's done there. You could do the same or similar manually, as warden hooks are not run in your case: https://github.com/waiting-for-dev/warden-jwt_auth/blob/master/lib/warden/jwt_auth/hooks.rb

janosrusiczki commented 1 year ago

I actually made it work! :smile:

It's more of a tweaking graphl-ruby type of solution. No monkey patching though.

In GraphQL controller you have to add Devise's sign_in method to the context like so:

# controllers/graphql_controller.rb
# ...
def execute
  query = params[:query]
  variables = prepare_variables(params[:variables])
  operation_name = params[:operationName]
  context = {
    current_user: current_user,
    sign_in: method(:sign_in) # LOOK AT ME
  }
  result = YourSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
  render json: result
rescue StandardError => e
  raise e unless Rails.env.development?
  handle_error_in_development(e)
end
# ...

Then you can call the method in your mutation:

# app/graphql/types/mutation_type.rb
module Types
  class MutationType < GraphQL::Schema::Object
    field :login, UserType, null: true do
      description 'Login'
      argument :email, String, required: true
      argument :password, String, required: true
    end

    def login(email:, password:)
      user = User.find_for_database_authentication(email: email)
      return unless user&.valid_password?(password)
      context[:sign_in].call(:user, user) # LOOK AT ME
      user
    end
  end
end

Don't forget to add your GraphQL endpoint to the list of JWT token dispatchers / revocators in Devise's initializer:

# config/initializers/devise.rb
# ...
config.jwt do |jwt|
  jwt.secret = ENV['JWT_SECRET_KEY']
  jwt.dispatch_requests = [
    ['POST', %r{^/graphql$}]
  ]
  jwt.revocation_requests = [
    ['POST', %r{^/graphql$}]
  ]
end
# ...
waiting-for-dev commented 1 year ago

Happy to know!! πŸ™‚ πŸš€ Can this issue be closed, now?

janosrusiczki commented 1 year ago

@waiting-for-dev - I spoke too soon. Everything worked well until I started implementing sign_out.

I uncommented the:

jwt.revocation_requests = [
  ['POST', %r{^/graphql$}]
]

part in config/initializers/devise.rb.

Now I can sign_in, I get an "Authorization" header, I use the token for the next page, I get authenticated, I receive a new token, I use this new token for the next page, but I don't get authenticated anymore and I don't receive a new token.

Is it because I'm using the same url / http verb for both dispatch_requests and revocations_requests?

waiting-for-dev commented 1 year ago

Is it because I'm using the same url / http verb for both dispatch_requests and revocations_requests?

Oh, yeah, that might cause conflicts for sure πŸ˜† You're probably better of creating and revoking the tokens by yourself.

janosrusiczki commented 1 year ago

I will investigate these days and report back, please keep the issue open.

janosrusiczki commented 1 year ago

I finally had the chance to take a look at this issue and I have a solution in mind.

I think it would be possible to add a third optional parameter to the dispatch_requests and revocation_requests tuples to inspect the request body for a specific string. In my case, with GraphQL, even though all requests go to POST /graphql, if I'd add a third parameter like 'signIn' for dispatch_requests and 'signOut' for revocation_requests Warden shouldn't confuse the two requests. It could even be a regex.

Something like:

# config/initializers/devise.rb
# ...
config.jwt do |jwt|
  jwt.secret = ENV['JWT_SECRET_KEY']
  jwt.dispatch_requests = [
    ['POST', %r{^/graphql$}, %r{signIn}]
  ]
  jwt.revocation_requests = [
    ['POST', %r{^/graphql$}, %{signOut}]
  ]
end
# ...

I will try to implement this on local copies of the gems. If it works out, are you interested in a pull request?

waiting-for-dev commented 1 year ago

Closing. See https://github.com/waiting-for-dev/warden-jwt_auth/pull/47#issuecomment-1406671037.