Skip to content

Commit

Permalink
feat: allow cors headers to be set by authenticators (#14)
Browse files Browse the repository at this point in the history
  • Loading branch information
adamcooke committed Aug 14, 2023
1 parent c579cbf commit 7afd076
Show file tree
Hide file tree
Showing 10 changed files with 219 additions and 60 deletions.
2 changes: 1 addition & 1 deletion apia.gemspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# frozen_string_literal: true

require_relative './lib/apia/version'
require_relative 'lib/apia/version'

Gem::Specification.new do |s|
s.name = 'apia'
Expand Down
10 changes: 10 additions & 0 deletions examples/core_api/main_authenticator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ class MainAuthenticator < Apia::Authenticator
end

def call
# Define a list of cors methods that are permitted for the request.
cors.methods = %w[GET POST PUT PATCH DELETE OPTIONS]

# Define a list of cors headers that are permitted for the request.
cors.headers = %w[X-Custom-Header]

# Define a the hostname to allow for CORS requests.
cors.origin = '*' # or 'example.com'
cors.origin = 'krystal.uk'

given_token = request.headers['authorization']&.sub(/\ABearer /, '')
case given_token
when 'example'
Expand Down
38 changes: 38 additions & 0 deletions lib/apia/cors.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true

module Apia
class CORS

attr_accessor :methods
attr_accessor :headers
attr_accessor :origin

def initialize
@origin = '*'
@methods = '*'
@headers = []
end

def to_headers
return {} if @origin.nil?

headers = {}
headers['Access-Control-Allow-Origin'] = @origin

if @methods.is_a?(String)
headers['Access-Control-Allow-Methods'] = @methods
elsif @methods.is_a?(Array) && @methods.any?
headers['Access-Control-Allow-Methods'] = @methods.map(&:upcase).join(', ')
end

if @headers.is_a?(String)
headers['Access-Control-Allow-Headers'] = @headers
elsif @headers.is_a?(Array) && @headers.any?
headers['Access-Control-Allow-Headers'] = @headers.join(', ')
end

headers
end

end
end
15 changes: 14 additions & 1 deletion lib/apia/endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,23 @@ def execute(request)
environment = RequestEnvironment.new(request, response)

catch_errors(response) do
# Determine an authenticator and execute it before the request happens
# Determine an authenticator for this endpoint
request.authenticator = definition.authenticator || request.controller&.definition&.authenticator || request.api&.definition&.authenticator

# Execute the authentication before the request happens
request.authenticator&.execute(environment)

# Add the CORS headers to the response before the endpoint is called. The endpoint
# cannot influence the CORS headers.
response.headers.merge!(environment.cors.to_headers)

# OPTIONS requests always return 200 OK and no body.
if request.options?
response.status = 200
response.body = ''
return response
end

# Determine if we're permitted to run the action based on the endpoint's scopes
if request.authenticator && !request.authenticator.authorized_scope?(environment, definition.scopes)
environment.raise_error Apia::ScopeNotGrantedError, scopes: definition.scopes
Expand Down
23 changes: 1 addition & 22 deletions lib/apia/rack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,7 @@ def call(env)

api_path = Regexp.last_match(1)

triplet = handle_request(env, api_path)
add_cors_headers(env, triplet)
triplet
handle_request(env, api_path)
end

private
Expand All @@ -77,10 +75,6 @@ def handle_request(env, api_path)
request_method = env['REQUEST_METHOD'].upcase
notify_hash = { api: api, env: env, path: api_path, method: request_method }

if request_method.upcase == 'OPTIONS'
return [204, {}, ['']]
end

Apia::Notifications.notify(:request_start, notify_hash)

validate_api if development?
Expand Down Expand Up @@ -155,21 +149,6 @@ def triplet_for_exception(exception)
)
end

# Add cross origin headers to the response triplet
#
# @param env [Hash]
# @param triplet [Array]
# @return [void]
def add_cors_headers(env, triplet)
triplet[1]['Access-Control-Allow-Origin'] = '*'
triplet[1]['Access-Control-Allow-Methods'] = '*'
if env['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']
triplet[1]['Access-Control-Allow-Headers'] = env['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']
end

true
end

class << self

# Return a JSON-ready triplet for the given body.
Expand Down
5 changes: 5 additions & 0 deletions lib/apia/request_environment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require 'apia/environment_error_handling'
require 'apia/errors/invalid_helper_error'
require 'apia/cors'

module Apia
class RequestEnvironment
Expand Down Expand Up @@ -74,6 +75,10 @@ def paginate(set, potentially_large_set: false)
@response.add_field :pagination, pagination_info
end

def cors
@cors ||= CORS.new
end

private

def potential_error_sources
Expand Down
78 changes: 78 additions & 0 deletions spec/specs/apia/cors_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# frozen_string_literal: true

require 'spec_helper'
require 'apia/cors'

describe Apia::CORS do
describe '#to_headers' do
subject(:cors) { described_class.new }

context 'with the details' do
it 'returns a wildcard origin and methods' do
expect(cors.to_headers).to eq({ 'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Methods' => '*' })
end
end

context 'when origin is set to nil' do
it 'returns an empty array' do
cors.origin = nil
expect(cors.to_headers).to eq({})
end
end

context 'when origin is set to a hostname' do
before do
cors.origin = 'example.com'
end

it 'includes the Access-Control-Allow-Origin header' do
expect(cors.to_headers).to eq({
'Access-Control-Allow-Origin' => 'example.com',
'Access-Control-Allow-Methods' => '*'
})
end

context 'when methods have been provided' do
it 'includes the Access-Control-Allow-Methods header' do
cors.methods = %w[GET POST]
expect(cors.to_headers).to eq({
'Access-Control-Allow-Origin' => 'example.com',
'Access-Control-Allow-Methods' => 'GET, POST'
})
end

it 'upcases any methods provided' do
cors.methods = %w[get post]
expect(cors.to_headers).to eq({
'Access-Control-Allow-Origin' => 'example.com',
'Access-Control-Allow-Methods' => 'GET, POST'
})
end
end

context 'when headers have been provided' do
it 'includes the Access-Control-Allow-Headers header' do
cors.headers = %w[X-Custom Content-Type]
expect(cors.to_headers).to eq({
'Access-Control-Allow-Origin' => 'example.com',
'Access-Control-Allow-Methods' => '*',
'Access-Control-Allow-Headers' => 'X-Custom, Content-Type'
})
end
end

context 'when methods and headers have been provided' do
it 'includes the Access-Control-Allow-Methods and Access-Control-Allow-Headers headers' do
cors.methods = %w[GET POST]
cors.headers = %w[X-Custom Content-Type]
expect(cors.to_headers).to eq({
'Access-Control-Allow-Origin' => 'example.com',
'Access-Control-Allow-Methods' => 'GET, POST',
'Access-Control-Allow-Headers' => 'X-Custom, Content-Type'
})
end
end
end
end
end
64 changes: 64 additions & 0 deletions spec/specs/apia/endpoint_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,70 @@
end
end

describe 'cors' do
context 'it includes CORS headers in the response' do
context 'when nothing is specified' do
it 'includes wildcard CORS headers' do
request = Apia::Request.new(Rack::MockRequest.env_for('/', input: ''))
endpoint = Apia::Endpoint.create('Endpoint')
response = endpoint.execute(request)
expect(response.headers['Access-Control-Allow-Origin']).to eq '*'
expect(response.headers['Access-Control-Allow-Methods']).to eq '*'
end
end

context 'when cors values are set by the authenticator' do
it 'includes the CORS headers from the authenticator in the response' do
request = Apia::Request.new(Rack::MockRequest.env_for('/', input: ''))

authenticator = Apia::Authenticator.create('ExampleAPIAuthenticator')
authenticator.action do
cors.origin = 'example.com'
cors.methods = 'GET, POST'
cors.headers = 'X-Custom'
end

endpoint = Apia::Endpoint.create('Endpoint') do
authenticator authenticator
end

response = endpoint.execute(request)
expect(response.headers['Access-Control-Allow-Origin']).to eq 'example.com'
expect(response.headers['Access-Control-Allow-Methods']).to eq 'GET, POST'
expect(response.headers['Access-Control-Allow-Headers']).to eq 'X-Custom'
end
end
end

context 'when the request is an OPTIONS request' do
it 'returns a 200 OK status' do
request = Apia::Request.new(Rack::MockRequest.env_for('/', input: '', method: 'OPTIONS'))
endpoint = Apia::Endpoint.create('Endpoint')
response = endpoint.execute(request)
expect(response.headers['Access-Control-Allow-Origin']).to eq '*'
expect(response.headers['Access-Control-Allow-Methods']).to eq '*'
expect(response.status).to eq 200
expect(response.body).to eq ''
end

it 'does not execute the endpoint' do
request = Apia::Request.new(Rack::MockRequest.env_for('/', input: '', method: 'OPTIONS'))
endpoint = Apia::Endpoint.create('Endpoint')
expect(endpoint).not_to receive(:new)
endpoint.execute(request)
end
end

context 'when the request is not an OPTIONS request' do
it 'executes the endpoint' do
request = Apia::Request.new(Rack::MockRequest.env_for('/', input: '', method: 'GET'))
endpoint = Apia::Endpoint.create('Endpoint')
expect(endpoint).to receive(:new).and_call_original
endpoint.execute(request)
end
end
end

it 'should catch runtime errors in the authenticator' do
request = Apia::Request.new(Rack::MockRequest.env_for('/', 'CONTENT_TYPE' => 'application/json', :input => '{"name":"Phillip"}'))
auth = Apia::Authenticator.create('MyAuthentication') do
Expand Down
36 changes: 0 additions & 36 deletions spec/specs/apia/rack_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,42 +94,6 @@ def call(_env)
end
end

context 'cors' do
it 'should return no content with CORS headers in response to all options requests' do
api = Apia::API.create('MyAPI')
rack = described_class.new(app, api, 'api/v1')
env = Rack::MockRequest.env_for('/api/v1/test', method: 'OPTIONS')
result = rack.call(env)
expect(result).to be_a Array
expect(result[0]).to eq 204
expect(result[2][0]).to be_empty
expect(result[1]['Access-Control-Allow-Origin']).to eq '*'
expect(result[1]['Access-Control-Allow-Methods']).to eq '*'
end

it 'should include cors headers on successful requests' do
controller = Apia::Controller.create('Controller') do
endpoint :test do
action do
response.add_header 'x-demo', 'hello'
end
end
end
api = Apia::API.create('MyAPI') do
routes do
get 'test', controller: controller, endpoint: :test
end
end
rack = described_class.new(app, api, 'api/v1')
env = Rack::MockRequest.env_for('/api/v1/test')
result = rack.call(env)
expect(result).to be_a Array
expect(result[0]).to eq 200
expect(result[1]['Access-Control-Allow-Origin']).to eq '*'
expect(result[1]['Access-Control-Allow-Methods']).to eq '*'
end
end

it 'should allow requests with matching hostnames through' do
controller = Apia::Controller.create('Controller') do
endpoint :test do
Expand Down
8 changes: 8 additions & 0 deletions spec/specs/apia/request_environment_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -264,4 +264,12 @@ def setup_api
expect(environment.response.fields[:widgets].last).to eq 's50'
end
end

context '#cors' do
subject(:environment) { setup_api }

it 'returns a CORS instance' do
expect(environment.cors).to be_a Apia::CORS
end
end
end

0 comments on commit 7afd076

Please sign in to comment.