waiting-for-dev/devise-jwt

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

janosrusiczki opened this issue ยท 9 comments

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

  • Version of devise-jwt in use: 0.10.0
  • Version of rails in use: 6.1.7
  • Version of warden-jwt_auth in use: 0.7.0
  • Output of Devise::JWT.config: => #<Dry::Configurable::Config values={:secret=>"fe32...", :decoding_secret=>"fe32...", :algorithm=>"HS256", :expiration_time=>86400, :dispatch_requests=>[], :revocation_requests=>[], :aud_header=>"JWT_AUD", :request_formats=>{}}>
  • Output of Warden::JWTAuth.config: => #<Dry::Configurable::Config values={:secret=>"fe32...", :decoding_secret=>"fe32...", :algorithm=>"HS256", :expiration_time=>86400, :aud_header=>"JWT_AUD", :mappings=>{:user=>User(id: integer, email: string, encrypted_password: string, remember_created_at: datetime, created_at: datetime, updated_at: datetime, jti: string)}, :dispatch_requests=>[], :revocation_requests=>[], :revocation_strategies=>{:user=>User(id: integer, email: string, encrypted_password: string, remember_created_at: datetime, created_at: datetime, updated_at: datetime, jti: string)}}>
  • Output of Devise.mappings: => {:user=>#<Devise::Mapping:0x000056010fc4f700 @scoped_path="users", @singular=:user, @class_name="User", @klass=#<Devise::Getter:0x000056010fc4f2f0 @name="User">, @path="users", @path_prefix=nil, @sign_out_via=:delete, @format=nil, @router_name=nil, @failure_app=Devise::FailureApp, @controllers={:sessions=>"devise/sessions"}, @path_names={:registration=>"", :new=>"new", :edit=>"edit", :sign_in=>"sign_in", :sign_out=>"sign_out"}, @modules=[:database_authenticatable, :rememberable, :jwt_authenticatable], @routes=[:session], @used_routes=[:session], @used_helpers=[:session]>}

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. ๐Ÿ˜‰

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

I actually made it work! ๐Ÿ˜„

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
# ...

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

@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?

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.

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

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?