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

Validate auth0 token #725

Merged
merged 9 commits into from
Oct 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
# frozen_string_literal: true

require "jwt"
require "auth0_client"

class ApplicationController < ActionController::API
include Secured
waynekoepcke marked this conversation as resolved.
Show resolved Hide resolved

rescue_from ActionController::ParameterMissing do
head :bad_request
end
Expand Down
46 changes: 46 additions & 0 deletions app/controllers/concerns/secured.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# frozen_string_literal: true

# This concern is included in our ApplicationController.
# It instantiates an Auth0Client, which calls the validate_token method
# to validate Auth tokens included in requests from the front-end.
module Secured
extend ActiveSupport::Concern

REQUIRES_AUTHENTICATION = { message: 'Requires authentication' }.freeze
BAD_CREDENTIALS = { message: 'Bad credentials' }.freeze
MALFORMED_AUTHORIZATION_HEADER = {
error: 'invalid_request',
error_description: 'Authorization header value must follow this format: Bearer access-token',
message: 'Bad credentials'
}.freeze

# The authorize method can be run as a before_action within a controller to validate
# a user's token prior to allowing access to an endpoint.
def authorize
token = token_from_request

return if performed?

validation_response = Auth0Client.validate_token(token)
error = validation_response.error
return unless error

render json: { message: error.message }, status: error.status
end

private

def token_from_request
authorization_header_elements = request.headers['Authorization']&.split

render json: REQUIRES_AUTHENTICATION, status: :unauthorized and return unless authorization_header_elements

render json: MALFORMED_AUTHORIZATION_HEADER, status: :unauthorized and return unless authorization_header_elements.length == 2

scheme, token = authorization_header_elements

render json: BAD_CREDENTIALS, status: :unauthorized and return unless scheme.downcase == 'bearer'

token
end
end
1 change: 1 addition & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class Application < Rails::Application
config.x.auth0.client_id = ENV['AUTH0_CLIENT_ID']
config.x.auth0.client_secret = ENV['AUTH0_CLIENT_SECRET']
config.x.auth0.domain = ENV['AUTH0_DOMAIN']
config.x.auth0.audience = ENV['AUTH0_AUDIENCE']

config.middleware.insert_before 0, Rack::Cors do
allow do
Expand Down
41 changes: 41 additions & 0 deletions lib/auth0_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

require 'jwt'
require 'net/http'

class Auth0Client
Auth_Domain = URI::HTTPS.build(host: Rails.application.config.x.auth0.domain)
Error = Struct.new(:message, :status)
Response = Struct.new(:decoded_token, :error)

def self.decode_token(token, jwks_hash)
JWT.decode(token, nil, true, {
algorithm: 'RS256',
iss: URI.join(Auth_Domain, '/'), # Trailing slash is required to successfully validate token
verify_iss: true,
aud: Rails.application.config.x.auth0.audience,
verify_aud: true,
jwks: { keys: jwks_hash[:keys] }
})
end

def self.fetch_jwks
jwks_uri = URI.join(Auth_Domain, '.well-known/jwks.json')
Net::HTTP.get_response jwks_uri
Copy link
Member

Choose a reason for hiding this comment

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

Is this something we should be caching? I'm a bit concerned about performance and reliability if every request to one of our auth-guarded routes also has to perform an HTTP request to Auth0 before being able to validate JWTs.

I only did a quick Google search on it, but I found this StackOverflow post that says that the JWKS keys are usually valid for months at a time. That StackOverflow post also says that the ordinary HTTP Cache Control headers on the HTTP response should let us know how long it should be valid for, so we could even build in a little cache expiration thing that first checks if our JWKS tokens are valid, and only if it isn't, then fetch a new one from Auth0.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great point. I dug a bit into Auth0's docs, as well as community questions, and it seems like JWKS keys issued by this endpoint may not have an expiration date AT ALL. We could cache them indefinitely and/or for some arbitrary time, like a month? I think it is maybe a good idea to punt this for now and maybe it bundle it up with our user authorization implementation. I created a ticket for this in the meantime: #726

end

def self.validate_token(token)
jwks_response = fetch_jwks

unless jwks_response.is_a? Net::HTTPSuccess
error = Error.new(message: 'Unable to verify credentials', status: :internal_server_error)
Response.new(nil, error)
end

jwks_hash = JSON.parse(jwks_response.body).deep_symbolize_keys
decoded_token = decode_token(token, jwks_hash)
Response.new(decoded_token, nil)
rescue JWT::DecodeError
Response.new(nil, Error.new('Bad credentials', :unauthorized))
end
end
Loading