Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

config: add CORS_TOUJOURS_TOKEN_HEADER env configuration #13

Merged
merged 1 commit into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
paulRbr marked this conversation as resolved.
Show resolved Hide resolved

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