diff --git a/Gemfile b/Gemfile index 1916064..7388a7a 100644 --- a/Gemfile +++ b/Gemfile @@ -4,15 +4,14 @@ source "https://rubygems.org" gem "jwt", "~> 2.9" gem "sinatra", "~> 4.0" +gem "rackup", "~> 2.1" gem "puma" - group :development, :test do gem "rspec", "~> 3.13" gem "rack-test", "~> 2.1" gem "debug", ">= 1.0.0" gem "webmock" gem "dotenv" + gem "standardrb", "~> 1.0" end - -gem "rackup", "~> 2.1" diff --git a/Gemfile.lock b/Gemfile.lock index 8b24180..ed2600f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ GEM specs: addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + ast (2.4.2) base64 (0.2.0) bigdecimal (3.1.8) crack (1.0.0) @@ -18,16 +19,24 @@ GEM irb (1.14.1) rdoc (>= 4.0.0) reline (>= 0.4.2) + json (2.7.4) jwt (2.9.3) base64 + language_server-protocol (3.17.0.3) + lint_roller (1.1.0) mustermann (3.0.3) ruby2_keywords (~> 0.0.1) nio4r (2.7.3) + parallel (1.26.3) + parser (3.3.5.0) + ast (~> 2.4.1) + racc psych (5.1.2) stringio public_suffix (6.0.1) puma (6.4.3) nio4r (~> 2.0) + racc (1.8.1) rack (3.1.8) rack-protection (4.0.0) base64 (>= 0.1.0) @@ -39,8 +48,10 @@ GEM rackup (2.1.0) rack (>= 3) webrick (~> 1.8) + rainbow (3.1.1) rdoc (6.7.0) psych (>= 4.0.0) + regexp_parser (2.9.2) reline (0.5.10) io-console (~> 0.5) rexml (3.3.9) @@ -57,6 +68,22 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.1) + rubocop (1.66.1) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.4, < 3.0) + rubocop-ast (>= 1.32.2, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.32.3) + parser (>= 3.3.1.0) + rubocop-performance (1.22.1) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) sinatra (4.0.0) mustermann (~> 3.0) @@ -64,8 +91,23 @@ GEM rack-protection (= 4.0.0) rack-session (>= 2.0.0, < 3) tilt (~> 2.0) + standard (1.41.1) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.66.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.5) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.5.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.22.0) + standardrb (1.0.1) + standard stringio (3.1.1) tilt (2.4.0) + unicode-display_width (2.6.0) webmock (3.24.0) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -85,6 +127,7 @@ DEPENDENCIES rackup (~> 2.1) rspec (~> 3.13) sinatra (~> 4.0) + standardrb (~> 1.0) webmock BUNDLED WITH diff --git a/README.md b/README.md index a03f90b..f83160d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Proxy Server with JWT Authentication -This is a lightweight HTTP proxy server built using the Sinatra framework. It acts as a pass-through proxy, allowing requests to be forwarded to a specified target URL. Additionally, it provides JWT (JSON Web Token) authentication to secure requests. +This is a lightweight HTTP proxy server built using the Sinatra framework. It acts as a pass-through proxy, allowing requests to be forwarded to a specified target URL. Additionally, it provides JWT (JSON Web Token) authentication to secure requests. ## Features - **CORS Support**: Handles CORS headers, allowing cross-origin requests. - **JWT Authentication**: Verifies the presence and validity of the `x-bump-jwt-token` header to ensure requests are authorized. -- **Flexible HTTP Method Support**: Supports `GET`, `POST`, `PUT`, and `DELETE` methods for forwarding client requests to the target server. +- **Flexible HTTP Method Support**: Supports `GET`, `POST`, `PUT`,`PATCH`, and `DELETE` methods for forwarding client requests to the target server. - **Automatic Request Forwarding**: Forwards requests to the specified target URL while preserving headers and request bodies. ## Getting Started @@ -52,11 +52,11 @@ The server verifies the `x-bump-jwt-token` for every request. If the token is mi The server provides the following endpoints for request forwarding: -- **GET** `/proxy?url=your-target-url` -- **POST** `/proxy?url=your-target-url` -- **PUT** `/proxy?url=your-target-url` -- **PATCH** `/proxy?url=your-target-url` -- **DELETE** `/proxy?url=your-target-url` +- **GET** `?url=your-target-url` +- **POST** `?url=your-target-url` +- **PUT** `?url=your-target-url` +- **PATCH** `?url=your-target-url` +- **DELETE** `?url=your-target-url` Each endpoint forwards the request to the target URL specified in the query parameter. @@ -64,12 +64,12 @@ Each endpoint forwards the request to the target URL specified in the query para **GET request:** ```bash -curl -X GET "http://localhost:4567/proxy?url=https://jsonplaceholder.typicode.com/posts" -H "x-bump-jwt-token: YOUR_TOKEN" +curl -X GET "http://localhost:4567/?url=https://jsonplaceholder.typicode.com/posts" -H "x-bump-jwt-token: YOUR_TOKEN" ``` **POST request:** ```bash -curl -X POST "http://localhost:4567/proxy?url=https://jsonplaceholder.typicode.com/posts" \ +curl -X POST "http://localhost:4567/?url=https://jsonplaceholder.typicode.com/posts" \ -H "Content-Type: application/json" \ -H "x-bump-jwt-token: YOUR_TOKEN" \ -d '{"title":"foo","body":"bar","userId":1}' @@ -86,7 +86,7 @@ The server includes CORS headers for cross-origin access. Preflight OPTIONS requ ## License -This project is licensed under the MIT License. +This project is licensed under the MIT License. ## Contributing diff --git a/config.ru b/config.ru index 2671e5b..8dcc0ba 100644 --- a/config.ru +++ b/config.ru @@ -1,4 +1,4 @@ # config.ru -require './proxy_server' # Adjust the path if necessary -run Sinatra::Application +require "./proxy_server" # Adjust the path if necessary +run ProxyServer diff --git a/config/puma.rb b/config/puma.rb index 0fd38b3..f53cb8f 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -1,15 +1,15 @@ # config/puma.rb -workers Integer(ENV['WEB_CONCURRENCY'] || 2) # Number of worker processes -threads_count = Integer(ENV['MAX_THREADS'] || 5) # Max threads per worker +workers Integer(ENV["WEB_CONCURRENCY"] || 2) # Number of worker processes +threads_count = Integer(ENV["MAX_THREADS"] || 5) # Max threads per worker threads threads_count, threads_count preload_app! # Define the rackup file -rackup 'config.ru' # Create this file next -port ENV['PORT'] || 4567 -environment ENV['RACK_ENV'] || 'development' +rackup "config.ru" # Create this file next +port ENV["PORT"] || 4567 +environment ENV["RACK_ENV"] || "development" on_worker_boot do # Worker specific setup for Rails 4.1+ diff --git a/proxy_server.rb b/proxy_server.rb index b0d19d5..5e51511 100644 --- a/proxy_server.rb +++ b/proxy_server.rb @@ -1,99 +1,105 @@ -require 'sinatra/base' -require 'net/http' -require 'uri' -require 'json' -require 'jwt' -require 'debug' +require "sinatra/base" +require "net/http" +require "uri" +require "json" +require "jwt" +require "debug" class ProxyServer < Sinatra::Base - set :port, 4567 # Secret key for JWT verification - SECRET_KEY = 'your-secret-key' + SECRET_KEY = "your-secret-key" # Handle CORS headers before do - headers 'Access-Control-Allow-Origin' => '*', - 'Access-Control-Allow-Methods' => ['OPTIONS', 'GET', 'POST', 'PUT', 'DELETE'], - 'Access-Control-Allow-Headers' => 'Content-Type, Authorization, x-bump-jwt-token' + headers "Access-Control-Allow-Origin" => "*", + "Access-Control-Allow-Methods" => ["OPTIONS", "GET", "POST", "PUT", "PATCH", "DELETE"], + "Access-Control-Allow-Headers" => "Content-Type, Authorization, x-bump-proxy-token, x-requested-with" end # Verify JWT token presence and signature before do - token = request.env['HTTP_X_BUMP_JWT_TOKEN'] + token = request.env["HTTP_X_BUMP_JWT_TOKEN"] # Check if token is missing if token.nil? - headers 'Content-Type' => 'application/json' - halt 401, { error: 'x-bump-jwt-token header is missing' }.to_json + headers "Content-Type" => "application/json" + halt 401, {error: "x-bump-jwt-token header is missing"}.to_json end # Verify JWT token begin - JWT.decode(token, SECRET_KEY, true, { algorithm: 'HS256' }) + JWT.decode(token, SECRET_KEY, true, {algorithm: "HS256"}) rescue JWT::DecodeError - halt 401, { error: 'Invalid token' }.to_json + halt 401, {error: "Invalid token"}.to_json end end # OPTIONS request for preflight - options '*' do + options "*" do 200 end helpers do def forward_request(method) - target_url = params['url'] + target_url = params["url"] uri = URI.parse(target_url) # Set up the request to the target server - target_request = case method - when 'GET' then Net::HTTP::Get.new(uri) - when 'POST' then Net::HTTP::Post.new(uri) - when 'PUT' then Net::HTTP::Put.new(uri) - when 'DELETE' then Net::HTTP::Delete.new(uri) - end + target_request = + case method + when "GET" then Net::HTTP::Get.new(uri) + when "POST" then Net::HTTP::Post.new(uri) + when "PUT" then Net::HTTP::Put.new(uri) + when "PATCH" then Net::HTTP::Patch.new(uri) + when "DELETE" then Net::HTTP::Delete.new(uri) + end # Transfer relevant headers from the client to the target request - client_headers = request.env.select { |key, _| key.start_with?('HTTP_') } + client_headers = request.env.select { |key, _| key.start_with?("HTTP_") } client_headers.each do |header, value| - formatted_header = header.sub('HTTP_', '').split('_').map(&:capitalize).join('-') - target_request[formatted_header] = value unless formatted_header == 'X-Bump-Jwt-Token' + formatted_header = header.sub("HTTP_", "").split("_").map(&:capitalize).join("-") + target_request[formatted_header] = value unless formatted_header == "X-Bump-Jwt-Token" end # Forward request body for POST and PUT methods - if %w[POST PUT].include?(method) + if %w[POST PUT PATCH].include?(method) target_request.content_type = request.content_type target_request.body = request.body.read end # Execute the request to the target server - Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| + Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http| response = http.request(target_request) # Pass the target server response back to the client + puts response.read_body status response.code - headers 'Content-Type' => response.content_type + headers "Content-Type" => response.content_type body response.body end end end # Proxy endpoints - get '/proxy' do - forward_request('GET') + get "/" do + forward_request("GET") + end + + post "/" do + forward_request("POST") end - post '/proxy' do - forward_request('POST') + put "/" do + forward_request("PUT") end - put '/proxy' do - forward_request('PUT') + patch "/" do + forward_request("PATCH") end - delete '/proxy' do - forward_request('DELETE') + delete "/" do + forward_request("DELETE") end end diff --git a/spec/proxy_server_spec.rb b/spec/proxy_server_spec.rb index 8ac35d6..f60874e 100644 --- a/spec/proxy_server_spec.rb +++ b/spec/proxy_server_spec.rb @@ -1,80 +1,101 @@ -require 'rspec' -require 'rack/test' -require 'webmock/rspec' -require_relative '../proxy_server' -require 'jwt' +require "rspec" +require "rack/test" +require "webmock/rspec" +require_relative "../proxy_server" +require "jwt" -SECRET_KEY = 'your-secret-key' +SECRET_KEY = "your-secret-key" -describe 'ProxyServer' do +describe "ProxyServer" do include Rack::Test::Methods def app ProxyServer end - let(:valid_token) { JWT.encode({ data: 'test' }, SECRET_KEY, 'HS256') } - let(:invalid_token) { 'invalid.token.here' } - let(:target_url) { 'https://jsonplaceholder.typicode.com/posts' } + def expect_header(k, v) + expect(last_response.headers[k]).to eq v + end + + let(:valid_token) { JWT.encode({data: "test"}, SECRET_KEY, "HS256") } + let(:invalid_token) { "invalid.token.here" } + let(:target_url) { "https://jsonplaceholder.typicode.com/posts" } # Mock external requests with WebMock or a similar tool (if desired) before(:each) do - stub_request(:get, "https://jsonplaceholder.typicode.com/posts"). - to_return(status: 200, body: "", headers: {}) - stub_request(:put, "https://jsonplaceholder.typicode.com/posts/1"). - to_return(status: 200, body: {title: "updated title"}.to_json, headers: {}) - stub_request(:post, "https://jsonplaceholder.typicode.com/posts"). - to_return(status: 201, body: {title: "foo", body: "bar", userId: 1}.to_json, headers: {}) + stub_request(:get, "https://jsonplaceholder.typicode.com/posts") + .with(headers: {"x-foo": "bar"}) + .to_return(status: 200, body: "", headers: {}) + stub_request(:put, "https://jsonplaceholder.typicode.com/posts/1") + .to_return(status: 200, body: {title: "updated title"}.to_json, headers: {}) + stub_request(:post, "https://jsonplaceholder.typicode.com/posts") + .to_return(status: 201, body: {title: "foo", body: "bar", userId: 1}.to_json, headers: {}) end - context 'when x-bump-jwt-token is present' do - it 'returns 200 for a valid token' do - header 'x-bump-jwt-token', valid_token - get "/proxy?url=#{target_url}" + context "preflight request" do + before(:each) do + options "/?url=#{target_url}" + end + it "returns CORS headers" do + expect_header("access-control-allow-origin", "*") + end + end + + context "when x-bump-jwt-token is present" do + before(:each) do + header "x-bump-jwt-token", valid_token + header "x-foo", "bar" + get "/?url=#{target_url}" + end + it "returns 200 for a valid token" do expect(last_response.status).to eq(200) + expect_header("access-control-allow-origin", "*") end - it 'returns 401 for an invalid token' do - header 'x-bump-jwt-token', invalid_token - get "/proxy?url=#{target_url}" + it "returns 401 for an invalid token" do + header "x-bump-jwt-token", invalid_token + get "/?url=#{target_url}" expect(last_response.status).to eq(401) - expect(JSON.parse(last_response.body)['error']).to eq('Invalid token') + expect_header("access-control-allow-origin", "*") + expect(JSON.parse(last_response.body)["error"]).to eq("Invalid token") end end - context 'when x-bump-jwt-token is missing' do - it 'returns 401 Unauthorized' do - get "/proxy?url=#{target_url}" + context "when x-bump-jwt-token is missing" do + it "returns 401 Unauthorized" do + get "/?url=#{target_url}" expect(last_response.status).to eq(401) - expect(JSON.parse(last_response.body)['error']).to eq('x-bump-jwt-token header is missing') + expect_header("access-control-allow-origin", "*") + expect(JSON.parse(last_response.body)["error"]).to eq("x-bump-jwt-token header is missing") end end - context 'request forwarding' do - it 'forwards headers and body for POST requests' do - header 'x-bump-jwt-token', valid_token - header 'Content-Type', 'application/json' - post "/proxy?url=#{target_url}", { title: 'foo', body: 'bar', userId: 1 }.to_json + context "request forwarding" do + it "forwards headers and body for POST requests" do + header "x-bump-jwt-token", valid_token + header "Content-Type", "application/json" + post "/?url=#{target_url}", {title: "foo", body: "bar", userId: 1}.to_json expect(last_response.status).to eq(201) # Expect created status if target server responds as expected response_body = JSON.parse(last_response.body) - expect(response_body['title']).to eq('foo') - expect(response_body['body']).to eq('bar') - expect(response_body['userId']).to eq(1) + expect_header("access-control-allow-origin", "*") + expect(response_body["title"]).to eq("foo") + expect(response_body["body"]).to eq("bar") + expect(response_body["userId"]).to eq(1) end - it 'forwards headers and body for PUT requests' do - header 'x-bump-jwt-token', valid_token - header 'Content-Type', 'application/json' - put "/proxy?url=#{target_url}/1", { id: 1, title: 'updated title' }.to_json + it "forwards headers and body for PUT requests" do + header "x-bump-jwt-token", valid_token + header "Content-Type", "application/json" + put "/?url=#{target_url}/1", {id: 1, title: "updated title"}.to_json expect(last_response.status).to eq(200) # Expect OK status if target server responds as expected response_body = JSON.parse(last_response.body) - expect(response_body['title']).to eq('updated title') + expect(response_body["title"]).to eq("updated title") end end end