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?
Closing. See waiting-for-dev/warden-jwt_auth#47 (comment).