From 725b6244689705d0ed0e634fe0b6d016f497038c Mon Sep 17 00:00:00 2001 From: "Paul B." Date: Wed, 27 Nov 2024 16:30:28 +0100 Subject: [PATCH] config: add `CORS_TOUJOURS_TOKEN_HEADER` env configuration This commit closes #7 by introducing a new optional `CORS_TOUJOURS_TOKEN_HEADER` env variable configuration that allows a proxy to use a custom named header for the JWT token. The default value is `x-cors-toujours-token` if the env variable is not set. --- README.md | 52 ++++++++++++++++++++++++++++----------- proxy_server.rb | 21 +++++++++++----- rotate_keys.rb | 2 +- spec/proxy_server_spec.rb | 45 ++++++++++++++++++++++----------- 4 files changed, 85 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 71f70a8..36838f5 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # 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 a JWT (JSON Web Token) verification mechanism to allow only specific requests. ## Features - **CORS Support**: Handles CORS headers, allowing cross-origin requests. -- **JWT Authentication**: Verifies the presence and validity of the `x-bump-proxy-token` header to ensure requests are authorized. +- **JWT verification**: Verifies the presence and validity of the `x-cors-toujours-token` header to ensure requests are allowed. - **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. - **Path Parameter Support**: Supports dynamic path parameters in URL patterns (e.g., `/posts/{post_id}/comments/{id}`). @@ -24,16 +24,27 @@ bundle install ### Configuration -Use the script to rotate the JWT signing keys: +The proxy is configured via environment variables. In local or test environment you can set those variables thanks to a `.env` file. + +#### JWT signing keys + +Use the following script to create your first JWT signing keys: ```bash ./rotate_keys.rb ``` -This will generate new RSA key pairs and add them to the `.env` file with the following variables: -- `JWT_SIGNING_PUBLIC_KEY`: Public key for token verification -- `JWT_SIGNING_PRIVATE_KEY`: Private key for token signing -For the first launch the script will add the necessary keys to the .env file. + +This will generate a RSA key pair and add it to the `.env` file with the following variables: +- `JWT_SIGNING_PUBLIC_KEY`: Public key for token verification (used by the proxy) +- `JWT_SIGNING_PRIVATE_KEY`: Private key for token signing (used by clients making the requests) + If later on you need to rotate the keys you will need to remove them manually from the .env file before exectuting the script again. +#### `x-cors-toujours-token` header + +By default, the proxy will read the verification token from the header `x-cors-toujours-token`. You can change the name of the header by adding the following environment variable to your `.env` file: + +- `CORS_TOUJOURS_TOKEN_HEADER="x-my-custom-header-name"` + ### Starting the Server Locally Run the following command to start the server on port 4567: @@ -52,14 +63,27 @@ bundle exec rspec --color -fd spec ### Authentication -The server verifies the `x-bump-proxy-token` header for every request. The JWT token must contain the following claims: +The server verifies the `x-cors-toujours-token` header for every request (name of the header is customizable - see [configuration details](#configuration)). The JWT token must contain the following claims: - `servers`: Array of allowed target server URLs -- `verb`: Allowed HTTP method for the request (GET, POST, PUT, PATCH, or DELETE) +- `verb`: Allowed HTTP method for the request to be made (GET, POST, PUT, PATCH, or DELETE) - `path`: Allowed path pattern, supporting path parameters (e.g., `/posts/{post_id}`) - `exp`: Token expiration timestamp -If the token is missing, invalid, or doesn't meet these requirements, the request will be rejected. +E.g.: A client sending a request to `POST https://bump.sh/api/v1/ping` +via the proxy will need to include an encoded JWT token in +the `x-cors-toujours-token` header, whose decoded value would look like: +```json +{ + "servers": ["https://bump.sh"], + "path": "/api/v1/ping", + "verb": "POST", + "exp": "2025-01-01T00:00:00Z" +} +``` + +If the token is missing, invalid, or doesn't meet those requirements, +the request will be rejected with a `403 Forbidden` HTTP error. ### Path Parameters @@ -72,14 +96,14 @@ The server supports dynamic path parameters in URL patterns. For example: **GET request:** ```bash curl -X GET "http://localhost:4567/https://jsonplaceholder.typicode.com/todos" \ - -H "x-bump-proxy-token: YOUR_JWT_TOKEN" + -H "x-cors-toujours-token: YOUR_JWT_TOKEN" ``` **PATCH request:** ```bash curl -X PATCH "http://localhost:4567/https://jsonplaceholder.typicode.com/posts/1" \ -H "Content-Type: application/json" \ - -H "x-bump-proxy-token: YOUR_JWT_TOKEN" \ + -H "x-cors-toujours-token: YOUR_JWT_TOKEN" \ -d '{"title":"foo"}' ``` @@ -88,7 +112,7 @@ curl -X PATCH "http://localhost:4567/https://jsonplaceholder.typicode.com/posts/ The server includes the following CORS headers for cross-origin access: - `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` +- `Access-Control-Allow-Headers: Content-Type, Authorization, x-cors-toujours-token, x-requested-with` Preflight OPTIONS requests are handled automatically. @@ -97,7 +121,7 @@ Preflight OPTIONS requests are handled automatically. The server returns different status codes based on various error conditions: - **401 Unauthorized**: - - Missing `x-bump-proxy-token` header + - Missing `x-cors-toujours-token` header - Invalid JWT token - Expired token diff --git a/proxy_server.rb b/proxy_server.rb index 6c206d1..cbb1ede 100644 --- a/proxy_server.rb +++ b/proxy_server.rb @@ -19,6 +19,11 @@ class ProxyServer < Sinatra::Base ENV.fetch("JWT_SIGNING_PUBLIC_KEY").gsub("\\n", "\n") ).freeze + TOKEN_HEADER = ENV.fetch( + "CORS_TOUJOURS_TOKEN_HEADER_NAME", + "x-cors-toujours-token" + ).split("_").join("-").downcase.freeze + error JWT::ExpiredSignature do headers "Content-Type" => "application/json" halt 401, {error: "Token has expired"}.to_json @@ -43,18 +48,19 @@ class ProxyServer < Sinatra::Base before do 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" + "Access-Control-Allow-Headers" => "Content-Type, Authorization, #{::ProxyServer::TOKEN_HEADER}, x-requested-with" end # Verify JWT token presence and signature before do if request.env["REQUEST_METHOD"] != "OPTIONS" - token = request.env["HTTP_X_BUMP_PROXY_TOKEN"] + token_header = ::ProxyServer::TOKEN_HEADER.split("-").join("_").upcase + token = request.get_header("HTTP_#{token_header}") # Check if token is missing if token.nil? headers "Content-Type" => "application/json" - halt 401, {error: "x-bump-proxy-token header is missing"}.to_json + halt 401, {error: "#{::ProxyServer::TOKEN_HEADER} header is missing"}.to_json end # Verify JWT token @@ -128,10 +134,13 @@ def forward_request(method) end # Transfer relevant headers from the client to the target request - client_headers = request.env.select { |key, _| key.start_with?("HTTP_") } - client_headers.each do |header, value| + request.each_header do |header, value| formatted_header = header.sub("HTTP_", "").split("_").map(&:capitalize).join("-") - target_request[formatted_header] = value unless formatted_header == "X-Bump-Proxy-Token" + + next unless header.start_with?("HTTP_") + next if formatted_header.downcase == ::ProxyServer::TOKEN_HEADER + + target_request[formatted_header] = value end # Forward request body for POST, PUT and PATCH methods diff --git a/rotate_keys.rb b/rotate_keys.rb index e3af6d4..f8b991b 100755 --- a/rotate_keys.rb +++ b/rotate_keys.rb @@ -16,7 +16,7 @@ # Display the public key in PEM format puts "\nPublic Key:" -public_key = rsa_key.public_key.to_pem +public_key = rsa_key.public_key.to_pem puts public_key `echo 'JWT_SIGNING_PUBLIC_KEY="#{public_key}"' >> ./.env` diff --git a/spec/proxy_server_spec.rb b/spec/proxy_server_spec.rb index 85774eb..9cfb39f 100644 --- a/spec/proxy_server_spec.rb +++ b/spec/proxy_server_spec.rb @@ -68,7 +68,7 @@ def expect_json_body(k, v) end end - context "when x-bump-jwt-token is present" do + context "when x-cors-toujours-token is present" do context "and is valid" do context "when no path params" do before(:each) do @@ -87,7 +87,7 @@ def expect_json_body(k, v) ) .to_return(status: 200, body: "", headers: {}) end - header "x-bump-proxy-token", proxy_token + header "x-cors-toujours-token", proxy_token header "x-foo", "bar" get "/#{target_url}" end @@ -96,6 +96,23 @@ def expect_json_body(k, v) expect(last_response.status).to eq(200) end + context "when header name is changed via configuration" do + before(:each) do + stub_const('ProxyServer::TOKEN_HEADER', "x-custom-proxy") + + # Replace token header with newly configured header name + header "x-cors-toujours-token", nil + header "x-custom_proxy", proxy_token + + # Send a new request + get "/#{target_url}" + end + + it "returns 200" do + expect(last_response.status).to eq(200) + end + end + context "when server contains some path like /api/v1" do let(:payload) do { @@ -166,7 +183,7 @@ def expect_json_body(k, v) }) .to_return(status: 200, body: "", headers: {}) - header "x-bump-proxy-token", proxy_token + header "x-cors-toujours-token", proxy_token header "x-foo", "bar" get "/#{target_url}" @@ -189,7 +206,7 @@ def expect_json_body(k, v) before(:each) do stub_request(:post, "https://jsonplaceholder.typicode.com/posts") .to_return(status: 201, body: {title: "foo", body: "bar", userId: 1}.to_json, headers: {}) - header "x-bump-proxy-token", proxy_token + header "x-cors-toujours-token", proxy_token header "Content-Type", "application/json" post "/#{target_url}", request_body.to_json end @@ -225,7 +242,7 @@ def expect_json_body(k, v) before(:each) do stub_request(:put, "https://jsonplaceholder.typicode.com/posts/1") .to_return(status: 200, body: {title: "updated title"}.to_json, headers: {}) - header "x-bump-proxy-token", proxy_token + header "x-cors-toujours-token", proxy_token header "Content-Type", "application/json" put "/#{target_url}/1", {id: 1, title: "updated title"}.to_json end @@ -247,7 +264,7 @@ def expect_json_body(k, v) end stub_request(:get, "https://jsonplaceholder.typicode.com/posts") .to_raise(OpenSSL::SSL::SSLError) - header "x-bump-proxy-token", proxy_token + header "x-cors-toujours-token", proxy_token header "Content-Type", "application/json" get "/#{target_url}" end @@ -262,7 +279,7 @@ def expect_json_body(k, v) context "but is invalid" do before(:each) do - header "x-bump-proxy-token", invalid_proxy_token + header "x-cors-toujours-token", invalid_proxy_token get "/#{target_url}" end @@ -284,7 +301,7 @@ def expect_json_body(k, v) let(:exp) { Time.now.to_i - 500 } # 5 minutes ago before(:each) do - header "x-bump-proxy-token", proxy_token + header "x-cors-toujours-token", proxy_token header "x-foo", "bar" get "/#{target_url}" end @@ -308,7 +325,7 @@ def expect_json_body(k, v) end before(:each) do - header "x-bump-proxy-token", proxy_token + header "x-cors-toujours-token", proxy_token header "x-foo", "bar" get "/#{target_url}" end @@ -330,7 +347,7 @@ def expect_json_body(k, v) let(:verb) { "PATCH" } # wrong http method before(:each) do - header "x-bump-proxy-token", proxy_token + header "x-cors-toujours-token", proxy_token header "x-foo", "bar" get "/#{target_url}" end @@ -352,7 +369,7 @@ def expect_json_body(k, v) let(:servers) { ["https://staging.bump.sh/api/v1/"] } before(:each) do - header "x-bump-proxy-token", proxy_token + header "x-cors-toujours-token", proxy_token header "x-foo", "bar" get "/#{target_url}" end @@ -374,7 +391,7 @@ def expect_json_body(k, v) let(:path) { "/comments" } before(:each) do - header "x-bump-proxy-token", proxy_token + header "x-cors-toujours-token", proxy_token header "x-foo", "bar" get "/#{target_url}" end @@ -394,7 +411,7 @@ def expect_json_body(k, v) end end - context "when x-bump-proxy-token is missing" do + context "when x-cors-toujours-token is missing" do before(:each) do get "/#{target_url}" end @@ -408,7 +425,7 @@ def expect_json_body(k, v) end it "returns the correct error message in the response body" do - expect(JSON.parse(last_response.body)["error"]).to eq("x-bump-proxy-token header is missing") + expect(JSON.parse(last_response.body)["error"]).to eq("x-cors-toujours-token header is missing") end end