Skip to content

Commit

Permalink
config: add CORS_TOUJOURS_TOKEN_HEADER env configuration
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
paulRbr committed Nov 27, 2024
1 parent de04a4a commit 725b624
Show file tree
Hide file tree
Showing 4 changed files with 85 additions and 35 deletions.
52 changes: 38 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -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}`).
Expand All @@ -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:
Expand All @@ -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

Expand All @@ -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"}'
```

Expand All @@ -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.

Expand All @@ -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

Expand Down
21 changes: 15 additions & 6 deletions proxy_server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion rotate_keys.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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`

45 changes: 31 additions & 14 deletions spec/proxy_server_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
{
Expand Down Expand Up @@ -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}"

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down

0 comments on commit 725b624

Please sign in to comment.