From 7afd076307f07588d18af0efe07f99d1cb08d90d Mon Sep 17 00:00:00 2001 From: Adam Cooke Date: Mon, 14 Aug 2023 16:46:19 +0100 Subject: [PATCH] feat: allow cors headers to be set by authenticators (#14) --- apia.gemspec | 2 +- examples/core_api/main_authenticator.rb | 10 +++ lib/apia/cors.rb | 38 ++++++++++ lib/apia/endpoint.rb | 15 +++- lib/apia/rack.rb | 23 +----- lib/apia/request_environment.rb | 5 ++ spec/specs/apia/cors_spec.rb | 78 +++++++++++++++++++++ spec/specs/apia/endpoint_spec.rb | 64 +++++++++++++++++ spec/specs/apia/rack_spec.rb | 36 ---------- spec/specs/apia/request_environment_spec.rb | 8 +++ 10 files changed, 219 insertions(+), 60 deletions(-) create mode 100644 lib/apia/cors.rb create mode 100644 spec/specs/apia/cors_spec.rb diff --git a/apia.gemspec b/apia.gemspec index b657aa9..a695cb5 100644 --- a/apia.gemspec +++ b/apia.gemspec @@ -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' diff --git a/examples/core_api/main_authenticator.rb b/examples/core_api/main_authenticator.rb index 36b4897..f24e87e 100644 --- a/examples/core_api/main_authenticator.rb +++ b/examples/core_api/main_authenticator.rb @@ -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' diff --git a/lib/apia/cors.rb b/lib/apia/cors.rb new file mode 100644 index 0000000..9c5715e --- /dev/null +++ b/lib/apia/cors.rb @@ -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 diff --git a/lib/apia/endpoint.rb b/lib/apia/endpoint.rb index 52802ec..8049d8e 100644 --- a/lib/apia/endpoint.rb +++ b/lib/apia/endpoint.rb @@ -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 diff --git a/lib/apia/rack.rb b/lib/apia/rack.rb index ce4f6c7..485ccfd 100644 --- a/lib/apia/rack.rb +++ b/lib/apia/rack.rb @@ -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 @@ -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? @@ -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. diff --git a/lib/apia/request_environment.rb b/lib/apia/request_environment.rb index b8c8a82..d6db10c 100644 --- a/lib/apia/request_environment.rb +++ b/lib/apia/request_environment.rb @@ -2,6 +2,7 @@ require 'apia/environment_error_handling' require 'apia/errors/invalid_helper_error' +require 'apia/cors' module Apia class RequestEnvironment @@ -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 diff --git a/spec/specs/apia/cors_spec.rb b/spec/specs/apia/cors_spec.rb new file mode 100644 index 0000000..6e235ec --- /dev/null +++ b/spec/specs/apia/cors_spec.rb @@ -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 diff --git a/spec/specs/apia/endpoint_spec.rb b/spec/specs/apia/endpoint_spec.rb index 49ab23d..5d0dbe7 100644 --- a/spec/specs/apia/endpoint_spec.rb +++ b/spec/specs/apia/endpoint_spec.rb @@ -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 diff --git a/spec/specs/apia/rack_spec.rb b/spec/specs/apia/rack_spec.rb index 5934def..8710386 100644 --- a/spec/specs/apia/rack_spec.rb +++ b/spec/specs/apia/rack_spec.rb @@ -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 diff --git a/spec/specs/apia/request_environment_spec.rb b/spec/specs/apia/request_environment_spec.rb index 54542ba..022f457 100644 --- a/spec/specs/apia/request_environment_spec.rb +++ b/spec/specs/apia/request_environment_spec.rb @@ -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