diff --git a/.gitignore b/.gitignore
index 5bf25ccac6..ac86e8d3b5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -101,3 +101,10 @@ src/scaffolding.config
# Visual Studio Code
.vscode
+
+# AI config
+.claude/
+CLAUDE.md
+
+# User-specific files
+.local
\ No newline at end of file
diff --git a/README.md b/README.md
index 2927d8dcf6..401ed2039b 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ It's also possible to [locally test containers built from PRs in GitHub Containe
### Infrastructure setup
If the instance is executed for the first time, it must set up the required infrastructure. To do so, once the instance is configured to use the selected transport and persister, run it in setup mode. This can be done by using the `Setup {instance name}` launch profile that is defined in
-the `launchSettings.json` file of each instance. When started in setup mode, the instance will start as usual, execute the setup process, and exit. At this point the instance can be run normally by using the non-setup launch profile.
+the `launchSettings.json` file of each instance. When started in setup mode, the instance will start as usual, execute the setup process, and exit. At this point the instance can be run normally by using the non-setup launch profile.
## Secrets
@@ -56,6 +56,22 @@ Running all tests all the times takes a lot of resources. Tests are filtered bas
NOTE: If no variable is defined all tests will be executed.
+## Security Configuration
+
+Documentation for configuring security features:
+
+- [HTTPS Configuration](docs/https-configuration.md) - Configure HTTPS/TLS for secure connections
+- [Forwarded Headers](docs/forwarded-headers.md) - Configure X-Forwarded-* header handling for reverse proxy scenarios
+- [Authentication](docs/authentication.md) - Configure authentication for the HTTP API
+- [Hosting Guide](docs/hosting-guide.md) - Scenario based hosting options for ServiceControl
+
+Local testing guides:
+
+- [HTTPS Testing](docs/https-testing.md)
+- [Reverse Proxy Testing](docs/reverseproxy-testing.md)
+- [Forward Headers Testing](docs/forward-headers-testing.md)
+- [Authentication Testing](docs/authentication-testing.md)
+
## How to developer test the PowerShell Module
Steps:
diff --git a/docs/authentication-testing.md b/docs/authentication-testing.md
new file mode 100644
index 0000000000..13572da35b
--- /dev/null
+++ b/docs/authentication-testing.md
@@ -0,0 +1,762 @@
+# Local Authentication Testing
+
+This guide explains how to test authentication configuration for ServiceControl instances. This approach uses curl to test authentication enforcement and configuration endpoints.
+
+## Prerequisites
+
+- ServiceControl built locally (see main README for build instructions)
+- **Identity Provider (IdP) configured** - For real authentication testing (Scenarios 7+), you need an OIDC provider configured with:
+ - An API application registration (for ServiceControl)
+ - A client application registration (for ServicePulse)
+ - API scopes configured and permissions granted
+ - See [ServiceControl Authentication](https://docs.particular.net/servicecontrol/security/configuration/authentication) for example setups
+- curl (included with Windows 10/11, Git Bash, or WSL)
+- (Optional) For formatted JSON output: `npm install -g json` then pipe curl output through `| json`
+
+## Enabling Debug Logs
+
+To enable detailed logging for troubleshooting, set the `LogLevel` environment variable before starting each instance:
+
+```cmd
+set SERVICECONTROL_LOGLEVEL=Debug
+set SERVICECONTROL_AUDIT_LOGLEVEL=Debug
+set MONITORING_LOGLEVEL=Debug
+```
+
+**Valid log levels:** `Trace`, `Debug`, `Information` (or `Info`), `Warning` (or `Warn`), `Error`, `Critical` (or `Fatal`), `None` (or `Off`)
+
+Debug logs will show detailed authentication flow information including token validation, claims processing, and authorization decisions.
+
+## Instance Reference
+
+| Instance | Project Directory | Default Port | Environment Variable Prefix |
+|---------------------------|---------------------------------|--------------|-----------------------------|
+| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` |
+| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` |
+| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` |
+
+## How Authentication Works
+
+When authentication is enabled:
+
+1. All API requests must include a valid JWT bearer token in the `Authorization` header
+2. ServiceControl validates the token against the configured OIDC authority
+3. Requests without a valid token receive a `401 Unauthorized` response
+4. The `/api/authentication/configuration` endpoint returns authentication configuration for clients (like ServicePulse)
+
+## Configuration Methods
+
+Settings can be configured via:
+
+1. **Environment variables** (recommended for testing) - Easy to change between scenarios, no file edits needed
+2. **App.config** - Persisted settings, requires app restart after changes
+
+Both methods work identically. This guide uses environment variables for convenience during iterative testing.
+
+## Test Scenarios
+
+The following scenarios use ServiceControl (Primary) as an example. To test other instances, use the appropriate environment variable prefix and port.
+
+> [!IMPORTANT]
+> Set environment variables in the same terminal where you run `dotnet run`. Environment variables are scoped to the terminal session.
+> **Tip:** Check the application startup logs to verify which settings were applied. The authentication configuration is logged at startup.
+
+### Scenario 1: Authentication Disabled (Default)
+
+Test the default behavior where authentication is disabled and all requests are allowed.
+
+#### Clear environment variables and start ServiceControl
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=
+set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE=
+
+cd src\ServiceControl
+dotnet run
+```
+
+#### Test with curl (no authorization header)
+
+```cmd
+curl http://localhost:33333/api | json
+```
+
+**Expected output:**
+
+```json
+{
+ "description": "The management backend for the Particular Service Platform",
+ ...
+}
+```
+
+Requests succeed without authentication because `Authentication.Enabled` defaults to `false`.
+
+#### Check authentication configuration endpoint
+
+```cmd
+curl http://localhost:33333/api/authentication/configuration | json
+```
+
+**Expected output:**
+
+```json
+{
+ "enabled": false
+}
+```
+
+The configuration indicates authentication is disabled. Other fields are omitted when null.
+
+### Scenario 2: Authentication Enabled (No Token)
+
+Test that requests without a token are rejected when authentication is enabled.
+
+> [!NOTE]
+> This scenario requires a valid OIDC authority URL. For testing authentication enforcement without a real provider, you can use any HTTP URL - the request will fail before token validation because no token is provided.
+
+#### Clear/Set environment variables and start ServiceControl
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/common/v2.0
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/common/v2.0
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/access_as_user"]
+set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE=
+
+cd src\ServiceControl
+dotnet run
+```
+
+#### Test with curl (no authorization header)
+
+```cmd
+curl -v http://localhost:33333/api/endpoints 2>&1 | findstr /C:"HTTP/"
+```
+
+**Expected output:**
+
+```text
+< HTTP/1.1 401 Unauthorized
+```
+
+Requests without a token are rejected with `401 Unauthorized`.
+
+> [!NOTE]
+> The `/api` root endpoint and `/api/authentication/configuration` are marked as anonymous and will return 200 OK even with authentication enabled. Test protected endpoints like `/api/endpoints` to verify authentication enforcement.
+
+#### Check authentication configuration endpoint (no auth required)
+
+```cmd
+curl http://localhost:33333/api/authentication/configuration | json
+```
+
+**Expected output:**
+
+```json
+{
+ "enabled": true,
+ "clientId": "test-client-id",
+ "audience": "api://servicecontrol-test",
+ "apiScopes": "[\"api://servicecontrol-test/access_as_user\"]"
+}
+```
+
+The authentication configuration endpoint is accessible without authentication and returns the configuration that clients need to authenticate. The `authority` field is omitted when `ServicePulse.Authority` is not explicitly set (it defaults to the main Authority for ServicePulse clients).
+
+### Scenario 3: Authentication with Invalid Token
+
+Test that requests with an invalid token are rejected.
+
+#### Clear/Set environment variables and start ServiceControl
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/common/v2.0
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/common/v2.0
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/access_as_user"]
+set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE=
+
+cd src\ServiceControl
+dotnet run
+```
+
+#### Test with curl (invalid token)
+
+```cmd
+curl -v -H "Authorization: Bearer invalid-token-here" http://localhost:33333/api/endpoints 2>&1 | findstr /C:"HTTP/"
+```
+
+**Expected output:**
+
+```text
+< HTTP/1.1 401 Unauthorized
+```
+
+Invalid tokens are rejected with `401 Unauthorized`.
+
+### Scenario 4: Anonymous Endpoints
+
+Test that anonymous endpoints remain accessible when authentication is enabled.
+
+#### With ServiceControl still running from Scenario 2 or 3, test anonymous endpoints
+
+```cmd
+curl http://localhost:33333/api | json
+```
+
+**Expected output:**
+
+```json
+{
+ "description": "The management backend for the Particular Service Platform",
+ ...
+}
+```
+
+```cmd
+curl http://localhost:33333/api/authentication/configuration | json
+```
+
+**Expected output:**
+
+```json
+{
+ "enabled": true,
+ "clientId": "test-client-id",
+ "audience": "api://servicecontrol-test",
+ "apiScopes": "[\"api://servicecontrol-test/.default\"]"
+}
+```
+
+See [Authentication](authentication.md) for all anonymous endpoints.
+
+### Scenario 5: Validation Settings Warnings
+
+Test that disabling validation settings produces warnings in the logs.
+
+#### Start ServiceControl with relaxed validation
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/common/v2.0
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/common/v2.0
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/access_as_user"]
+set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER=false
+set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE=false
+
+cd src\ServiceControl
+dotnet run
+```
+
+**Expected log output:**
+
+```text
+warn: Authentication.ValidateIssuer is set to false. This is not recommended for production environments...
+warn: Authentication.ValidateAudience is set to false. This is not recommended for production environments...
+```
+
+The application warns about insecure validation settings.
+
+### Scenario 6: Missing Required Settings
+
+Test that missing required settings prevent startup.
+
+#### Start ServiceControl with missing authority
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/access_as_user"]
+set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE=
+
+cd src\ServiceControl
+dotnet run
+```
+
+**Expected behavior:**
+
+The application fails to start with an error message:
+
+```text
+Authentication.Authority is required when authentication is enabled. Please provide a valid OpenID Connect authority URL...
+```
+
+### Scenario 7: Authentication with Valid Token (Real Identity Provider)
+
+Test end-to-end authentication with a valid token from a real OIDC provider.
+
+> [!IMPORTANT]
+> **Prerequisites:** This scenario requires a configured OIDC provider (e.g., Microsoft Entra ID, Auth0, Okta).
+> See [ServiceControl Authentication](https://docs.particular.net/servicecontrol/security/configuration/authentication) for setup examples.
+
+#### Start ServiceControl with your Entra ID configuration
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id}
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"]
+set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE=
+
+cd src\ServiceControl
+dotnet run
+```
+
+#### Get a test token using Azure CLI
+
+```cmd
+az login
+az account get-access-token --resource api://servicecontrol --query accessToken -o tsv
+```
+
+#### Test with the token
+
+```cmd
+curl -H "Authorization: Bearer {token}" http://localhost:33333/api/endpoints | json
+```
+
+**Expected output:**
+
+```json
+[]
+```
+
+Requests with a valid token are processed successfully. The response will be an empty array if no endpoints are registered, or a list of endpoints if data exists.
+
+### Scenario 8: Scatter-Gather with Authentication (Token Forwarding)
+
+Test that the primary instance forwards authentication tokens to remote instances during scatter-gather operations.
+
+> [!NOTE]
+> When a client queries endpoints like `/api/messages`, the primary instance may query remote Audit instances to aggregate results. The client's authorization token is forwarded to these remote instances.
+
+**Prerequisites:**
+
+- A configured OIDC provider with valid tokens
+- All instances configured with the **same** Authority and Audience settings
+
+#### Terminal 1 - Start ServiceControl.Audit with authentication
+
+```cmd
+set SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol
+
+cd src\ServiceControl.Audit
+dotnet run
+```
+
+#### Terminal 2 - Start ServiceControl (Primary) with authentication and remote instance configured
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id}
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"]
+set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://localhost:44444"}]
+
+cd src\ServiceControl
+dotnet run
+```
+
+#### Get a test token and query the primary instance
+
+```cmd
+az login
+set TOKEN=$(az account get-access-token --resource api://servicecontrol --query accessToken -o tsv)
+
+curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/messages | json
+```
+
+#### How to verify token forwarding is working
+
+1. **Check the Audit instance logs (Terminal 1)** - When the request succeeds, you should see log entries showing the authenticated request was processed. Look for request logging that shows the `/api/messages` endpoint was called.
+
+2. **Check the response headers** - The aggregated response includes instance information:
+
+ ```cmd
+ curl -v -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/messages 2>&1 | findstr /C:"X-Particular"
+ ```
+
+ You should see headers indicating responses were received from remote instances.
+
+3. **Verify by stopping the Audit instance** - Stop the Audit instance and repeat the request. The response should now only contain local data, and the primary instance logs should show the remote is unavailable.
+
+4. **Test direct access to Audit instance** - Verify the Audit instance requires authentication independently:
+
+ ```cmd
+ REM Without token - should fail
+ curl -v https://localhost:44444/api/messages 2>&1 | findstr /C:"HTTP/"
+ REM Expected: < HTTP/1.1 401 Unauthorized
+
+ REM With token - should succeed
+ curl -H "Authorization: Bearer %TOKEN%" https://localhost:44444/api/messages | json
+ REM Expected: [] or list of messages
+ ```
+
+5. **Compare results** - If authentication forwarding is working correctly:
+ - Direct request to Audit with token: succeeds
+ - Direct request to Audit without token: fails with 401
+ - Request through Primary with token: succeeds and includes Audit data
+ - Request through Primary without token: fails with 401
+
+#### Test with no token (should fail)
+
+```cmd
+curl -v https://localhost:33333/api/messages 2>&1 | findstr /C:"HTTP/"
+```
+
+**Expected output:**
+
+```text
+< HTTP/1.1 401 Unauthorized
+```
+
+### Scenario 9: Scatter-Gather with Mismatched Authentication Configuration
+
+Test that scatter-gather fails gracefully when remote instances have different authentication settings.
+
+#### Terminal 1 - Start ServiceControl.Audit with DIFFERENT audience
+
+```cmd
+set SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol-audit-different
+
+cd src\ServiceControl.Audit
+dotnet run
+```
+
+#### Terminal 2 - Start ServiceControl (Primary)
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id}
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"]
+set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://localhost:44444"}]
+
+cd src\ServiceControl
+dotnet run
+```
+
+#### Query with a valid token for the primary instance
+
+```cmd
+curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/messages | json
+```
+
+#### How to verify the mismatch is detected
+
+1. **Check the Audit instance logs (Terminal 1)** - You should see a 401 Unauthorized response logged, with details about the token validation failure (audience mismatch):
+
+ ```text
+ warn: Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler
+ Bearer was not authenticated. Failure message: IDX10214: Audience validation failed...
+ ```
+
+2. **Check the Primary instance logs (Terminal 2)** - You should see the remote marked as temporarily unavailable:
+
+ ```text
+ warn: ... Remote instance at https://localhost:44444 returned status code Unauthorized
+ ```
+
+3. **Verify the remote status** - Check the remotes endpoint to confirm the Audit instance is marked as unavailable:
+
+ ```cmd
+ curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/configuration/remotes | json
+ ```
+
+ **Expected output:**
+
+ ```json
+ [
+ {
+ "api_uri": "https://localhost:44444",
+ "status": "unavailable"
+ }
+ ]
+ ```
+
+4. **Confirm direct access fails with the token** - The token is valid for Primary but not for Audit:
+
+ ```cmd
+ REM Direct to Audit - should fail (wrong audience)
+ curl -v -H "Authorization: Bearer %TOKEN%" https://localhost:44444/api/messages 2>&1 | findstr /C:"HTTP/"
+ REM Expected: < HTTP/1.1 401 Unauthorized
+ ```
+
+### Scenario 10: Remote Instance Health Checks with Authentication
+
+Test that the primary instance can check remote instance health when authentication is enabled.
+
+> [!NOTE]
+> The health check queries the `/api` endpoint on remote instances. This endpoint is marked as anonymous and should be accessible without authentication.
+
+Start both instances with authentication enabled (same configuration as Scenario 8).
+
+#### Check the remote instances configuration endpoint
+
+```cmd
+curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/configuration/remotes | json
+```
+
+**Expected output:**
+
+```json
+[
+ {
+ "api_uri": "https://localhost:44444",
+ "status": "online",
+ "version": "5.x.x"
+ }
+]
+```
+
+The health check should succeed because `/api` is an anonymous endpoint.
+
+### Scenario 11: Platform Connection Details with Authentication
+
+Test that platform connection details can be retrieved when authentication is enabled on remote instances.
+
+> [!NOTE]
+> The primary instance queries `/api/connection` on remote instances to aggregate platform connection details. This endpoint may require authentication.
+
+#### With both instances running (same as Scenario 8)
+
+```cmd
+curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/connection | json
+```
+
+**Expected behavior:**
+
+The platform connection response includes connection details from both the primary and remote instances.
+
+### Scenario 12: Mixed Authentication Configuration (Primary Only)
+
+Test behavior when only the primary instance has authentication enabled, but remote instances do not.
+
+#### Terminal 1 - Start ServiceControl.Audit WITHOUT authentication
+
+```cmd
+set SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=
+
+cd src\ServiceControl.Audit
+dotnet run
+```
+
+#### Terminal 2 - Start ServiceControl (Primary) WITH authentication
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id}
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"]
+set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://localhost:44444"}]
+
+cd src\ServiceControl
+dotnet run
+```
+
+#### Query with a valid token
+
+```cmd
+curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/messages | json
+```
+
+#### How to verify this mixed configuration works
+
+1. **Verify the Audit instance has no authentication** - Direct requests without a token should succeed:
+
+ ```cmd
+ REM Direct to Audit without token - should succeed (no auth required)
+ curl https://localhost:44444/api/messages | json
+ REM Expected: [] or list of messages
+ ```
+
+2. **Verify the Primary instance requires authentication** - Direct requests without a token should fail:
+
+ ```cmd
+ REM Direct to Primary without token - should fail
+ curl -v https://localhost:33333/api/messages 2>&1 | findstr /C:"HTTP/"
+ REM Expected: < HTTP/1.1 401 Unauthorized
+ ```
+
+3. **Check the Audit instance logs (Terminal 1)** - When queried through the Primary, you should see the request processed. The token is present in the request but ignored since authentication is disabled:
+
+ ```text
+ info: ... Processed request GET /api/messages
+ ```
+
+4. **Check the Primary instance logs (Terminal 2)** - You should see successful aggregation from the remote:
+
+ ```text
+ info: ... Successfully retrieved messages from remote https://localhost:44444
+ ```
+
+5. **Verify aggregation works** - The response from Primary should include data from both instances:
+
+ ```cmd
+ curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/configuration/remotes | json
+ ```
+
+ **Expected output:**
+
+ ```json
+ [
+ {
+ "api_uri": "https://localhost:44444",
+ "status": "online",
+ "version": "5.x.x"
+ }
+ ]
+ ```
+
+> [!WARNING]
+> This mixed configuration is not recommended for production. If the primary requires authentication, remote instances should also require authentication to maintain consistent security.
+
+### Scenario 13: Mixed Authentication Configuration (Remotes Only)
+
+Test behavior when remote instances have authentication enabled, but the primary does not.
+
+#### Terminal 1 - Start ServiceControl.Audit WITH authentication
+
+```cmd
+set SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol
+
+cd src\ServiceControl.Audit
+dotnet run
+```
+
+#### Terminal 2 - Start ServiceControl (Primary) WITHOUT authentication
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=
+set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://localhost:44444"}]
+
+cd src\ServiceControl
+dotnet run
+```
+
+#### Query without a token
+
+```cmd
+curl https://localhost:33333/api/messages | json
+```
+
+#### How to verify the degraded functionality
+
+1. **Verify the Primary instance has no authentication** - Direct requests without a token should succeed:
+
+ ```cmd
+ REM Direct to Primary without token - should succeed (no auth required)
+ curl https://localhost:33333/api | json
+ REM Expected: API root response
+ ```
+
+2. **Verify the Audit instance requires authentication** - Direct requests without a token should fail:
+
+ ```cmd
+ REM Direct to Audit without token - should fail
+ curl -v https://localhost:44444/api/messages 2>&1 | findstr /C:"HTTP/"
+ REM Expected: < HTTP/1.1 401 Unauthorized
+ ```
+
+3. **Check the Audit instance logs (Terminal 1)** - You should see 401 Unauthorized responses when the Primary tries to query it:
+
+ ```text
+ warn: Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler
+ Bearer was not authenticated. Failure message: No token provided...
+ ```
+
+4. **Check the Primary instance logs (Terminal 2)** - You should see the remote marked as temporarily unavailable:
+
+ ```text
+ warn: ... Remote instance at https://localhost:44444 returned status code Unauthorized
+ ```
+
+5. **Verify the remote is marked unavailable:**
+
+ ```cmd
+ curl https://localhost:33333/api/configuration/remotes | json
+ ```
+
+ **Expected output:**
+
+ ```json
+ [
+ {
+ "api_uri": "https://localhost:44444",
+ "status": "unavailable"
+ }
+ ]
+ ```
+
+6. **Confirm scatter-gather returns partial results** - The response only contains local Primary data, not aggregated Audit data. Any endpoints or messages stored in the Audit instance will be missing from the response.
+
+> [!WARNING]
+> This configuration results in degraded functionality. Remote instances will be inaccessible for scatter-gather operations.
+
+### Scenario 14: Expired Token Forwarding
+
+Test how scatter-gather handles expired tokens being forwarded to remote instances.
+
+With both instances running with authentication (same as Scenario 8).
+
+#### Use an expired token
+
+```cmd
+curl -v -H "Authorization: Bearer {expired-token}" https://localhost:33333/api/messages 2>&1 | findstr /C:"HTTP/"
+```
+
+**Expected output:**
+
+```text
+< HTTP/1.1 401 Unauthorized
+```
+
+The primary instance rejects the expired token before any remote requests are made.
+
+## See Also
+
+- [Authentication Configuration](authentication.md) - Configuration reference for authentication settings
+- [HTTPS Configuration](https-configuration.md) - HTTPS is recommended when authentication is enabled
+- [Forwarded Headers Testing](forward-headers-testing.md) - Testing forwarded headers
diff --git a/docs/authentication.md b/docs/authentication.md
new file mode 100644
index 0000000000..a629289bf0
--- /dev/null
+++ b/docs/authentication.md
@@ -0,0 +1,7 @@
+# Authentication
+
+See [ServiceControl Authentication](https://docs.particular.net/servicecontrol/security/configuration/authentication) on the public docs site.
+
+## See Also
+
+- [Authentication Testing](authentication-testing.md) - Testing authentication locally
diff --git a/docs/forward-headers-testing.md b/docs/forward-headers-testing.md
new file mode 100644
index 0000000000..cf3045b2e1
--- /dev/null
+++ b/docs/forward-headers-testing.md
@@ -0,0 +1,859 @@
+# Local Testing Forwarded Headers (Without NGINX)
+
+This guide explains how to test forwarded headers configuration for ServiceControl instances without using NGINX or Docker. This approach uses curl to manually send `X-Forwarded-*` headers directly to the instances.
+
+## Prerequisites
+
+- ServiceControl built locally (see main README for build instructions)
+- curl (included with Windows 10/11, Git Bash, or WSL)
+- (Optional) For formatted JSON output: `npm install -g json` then pipe curl output through `| json`
+- All commands assume you are in the respective project directory
+
+## Enabling Debug Logs
+
+To enable detailed logging for troubleshooting, set the `LogLevel` environment variable before starting each instance:
+
+```cmd
+rem ServiceControl Primary
+set SERVICECONTROL_LOGLEVEL=Debug
+
+rem ServiceControl.Audit
+set SERVICECONTROL_AUDIT_LOGLEVEL=Debug
+
+rem ServiceControl.Monitoring
+set MONITORING_LOGLEVEL=Debug
+```
+
+**Valid log levels:** `Trace`, `Debug`, `Information` (or `Info`), `Warning` (or `Warn`), `Error`, `Critical` (or `Fatal`), `None` (or `Off`)
+
+Debug logs will show detailed forwarded headers processing and trust evaluation information.
+
+## Instance Reference
+
+| Instance | Project Directory | Default Port | Environment Variable Prefix |
+|---------------------------|---------------------------------|--------------|-----------------------------|
+| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` |
+| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` |
+| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` |
+
+> **Note:** Environment variables must include the instance prefix (e.g., `SERVICECONTROL_FORWARDEDHEADERS_ENABLED` for the primary instance).
+
+## How Forwarded Headers Work
+
+When a ServiceControl instance is behind a reverse proxy, the proxy sends headers to indicate the original request details:
+
+- `X-Forwarded-For` - Original client IP address
+- `X-Forwarded-Proto` - Original protocol (http/https)
+- `X-Forwarded-Host` - Original host header
+
+Each instance can be configured to trust these headers from specific proxies or trust all proxies.
+
+### Trust Evaluation Rules
+
+The middleware determines whether to process forwarded headers based on these rules:
+
+1. **If `TrustAllProxies` = true**: All requests are trusted, headers are always processed
+2. **If `TrustAllProxies` = false**: The caller's IP must match **either**:
+ - **KnownProxies**: Exact IP address match (e.g., `127.0.0.1`, `::1`)
+ - **KnownNetworks**: CIDR range match (e.g., `127.0.0.0/8`, `10.0.0.0/8`)
+
+> **Important:** KnownProxies and KnownNetworks use **OR logic** - a match in either grants trust. The check is against the **immediate caller's IP** (the proxy connecting to ServiceControl), not the original client IP from `X-Forwarded-For`.
+
+## Configuration Methods
+
+Settings can be configured via:
+
+1. **Environment variables** (recommended for testing) - Easy to change between scenarios, no file edits needed
+2. **App.config** - Persisted settings, requires app restart after changes
+
+Both methods work identically. This guide uses environment variables for convenience during iterative testing.
+
+## Test Scenarios
+
+The following scenarios use ServiceControl (Primary) as an example. To test other instances:
+
+1. Navigate to the instance's project directory
+2. Use the instance's default port in the curl commands
+3. Optionally use the instance-specific environment variable prefix
+
+> **Important:** Set environment variables in the same terminal where you run `dotnet run`. Environment variables are scoped to the terminal session and won't be seen if you run from Visual Studio or a different terminal.
+>
+> **Tip:** Check the application startup logs to verify which settings were applied. The forwarded headers configuration is logged at startup.
+
+### Scenario 0: Direct Access (No Proxy)
+
+Test a direct request without any forwarded headers, simulating access without a reverse proxy.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+**Test with curl (no forwarded headers):**
+
+```cmd
+curl http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "http",
+ "host": "localhost:33333",
+ "remoteIpAddress": "::1"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": true,
+ "knownProxies": [],
+ "knownNetworks": []
+ }
+}
+```
+
+When no forwarded headers are sent, the request values remain unchanged.
+
+### Scenario 1: Default Behavior (With Headers)
+
+Test the default behavior when no forwarded headers environment variables are set, but headers are sent.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+**Test with curl:**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "example.com",
+ "remoteIpAddress": "203.0.113.50"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": true,
+ "knownProxies": [],
+ "knownNetworks": []
+ }
+}
+```
+
+By default, forwarded headers are **enabled** and **all proxies are trusted**. This means any client can spoof `X-Forwarded-*` headers. This is suitable for development but should be restricted in production by configuring `KnownProxies` or `KnownNetworks`.
+
+### Scenario 2: Trust All Proxies (Explicit)
+
+Explicitly enable trust all proxies (same as default, but explicit configuration).
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+**Test with curl:**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "example.com",
+ "remoteIpAddress": "203.0.113.50"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": true,
+ "knownProxies": [],
+ "knownNetworks": []
+ }
+}
+```
+
+The `scheme` is `https` (from `X-Forwarded-Proto`), `host` is `example.com` (from `X-Forwarded-Host`), and `remoteIpAddress` is `203.0.113.50` (from `X-Forwarded-For`) because all proxies are trusted. The `rawHeaders` are empty because the middleware consumed them.
+
+### Scenario 3: Known Proxies Only
+
+Only accept forwarded headers from specific IP addresses.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1,::1
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+> **Note:** Setting `SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES` automatically disables `SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES`. Both IPv4 (`127.0.0.1`) and IPv6 (`::1`) loopback addresses are included since curl may use either.
+
+**Test with curl (from localhost - should work):**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "example.com",
+ "remoteIpAddress": "203.0.113.50"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": false,
+ "knownProxies": ["127.0.0.1", "::1"],
+ "knownNetworks": []
+ }
+}
+```
+
+Headers are applied because the request comes from localhost, which is in the known proxies list. The `rawHeaders` are empty because the middleware consumed them.
+
+### Scenario 4: Known Networks (CIDR)
+
+Trust all proxies within a network range.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=127.0.0.0/8,::1/128
+
+dotnet run
+```
+
+> **Note:** Both IPv4 (`127.0.0.0/8`) and IPv6 (`::1/128`) loopback networks are included since curl may use either.
+
+**Test with curl:**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "example.com",
+ "remoteIpAddress": "203.0.113.50"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": false,
+ "knownProxies": [],
+ "knownNetworks": ["127.0.0.0/8", "::1/128"]
+ }
+}
+```
+
+Headers are applied because the request comes from localhost, which falls within the known networks. The `rawHeaders` are empty because the middleware consumed them.
+
+### Scenario 5: Unknown Proxy Rejected
+
+Configure a known proxy that doesn't match the request source to verify headers are ignored.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=192.168.1.100
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+**Test with curl:**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "http",
+ "host": "localhost:33333",
+ "remoteIpAddress": "::1"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "203.0.113.50",
+ "xForwardedProto": "https",
+ "xForwardedHost": "example.com"
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": false,
+ "knownProxies": ["192.168.1.100"],
+ "knownNetworks": []
+ }
+}
+```
+
+Headers are **ignored** because the request comes from localhost (`::1`), which is NOT in the known proxies list (`192.168.1.100`). Notice `scheme` is `http` (unchanged from original request). The `rawHeaders` still show the headers that were sent but not applied.
+
+### Scenario 6: Unknown Network Rejected
+
+Configure a known network that doesn't match the request source to verify headers are ignored.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=10.0.0.0/8,192.168.0.0/16
+
+dotnet run
+```
+
+**Test with curl:**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "http",
+ "host": "localhost:33333",
+ "remoteIpAddress": "::1"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "203.0.113.50",
+ "xForwardedProto": "https",
+ "xForwardedHost": "example.com"
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": false,
+ "knownProxies": [],
+ "knownNetworks": ["10.0.0.0/8", "192.168.0.0/16"]
+ }
+}
+```
+
+Headers are **ignored** because the request comes from localhost (`::1`), which is NOT in the known networks (`10.0.0.0/8` or `192.168.0.0/16`). Notice `scheme` is `http` (unchanged from original request). The `rawHeaders` still show the headers that were sent but not applied.
+
+### Scenario 7: Forwarded Headers Disabled
+
+Completely disable forwarded headers processing.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=false
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+**Test with curl:**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "http",
+ "host": "localhost:33333",
+ "remoteIpAddress": "::1"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "203.0.113.50",
+ "xForwardedProto": "https",
+ "xForwardedHost": "example.com"
+ },
+ "configuration": {
+ "enabled": false,
+ "trustAllProxies": false,
+ "knownProxies": [],
+ "knownNetworks": []
+ }
+}
+```
+
+Headers are ignored because forwarded headers processing is disabled entirely. Notice `enabled` is `false` in the configuration.
+
+### Scenario 8: Proxy Chain (Multiple X-Forwarded-For Values)
+
+Test how ServiceControl handles multiple proxies in the chain.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+**Test with curl (simulating a proxy chain):**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "example.com",
+ "remoteIpAddress": "203.0.113.50"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": true,
+ "knownProxies": [],
+ "knownNetworks": []
+ }
+}
+```
+
+The `X-Forwarded-For` header contains multiple IPs representing the proxy chain. When `TrustAllProxies` is `true`, `ForwardLimit` is set to `null` (no limit), so the middleware processes all IPs and returns the original client IP (`203.0.113.50`).
+
+### Scenario 9: Proxy Chain with Known Proxies (ForwardLimit = 1)
+
+Test how ServiceControl handles multiple proxies when `TrustAllProxies` is `false`. In this case, `ForwardLimit` remains at its default of `1`, so only the last proxy IP is processed.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1,::1
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+**Test with curl (simulating a proxy chain):**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "example.com",
+ "remoteIpAddress": "192.168.1.1"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "203.0.113.50, 10.0.0.1",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": false,
+ "knownProxies": ["127.0.0.1", "::1"],
+ "knownNetworks": []
+ }
+}
+```
+
+When `TrustAllProxies` is `false`, `ForwardLimit` remains at its default of `1`. The middleware only processes the rightmost IP from the chain (`192.168.1.1`). The remaining IPs (`203.0.113.50, 10.0.0.1`) stay in the `X-Forwarded-For` header. Compare this to Scenario 8 where `TrustAllProxies = true` returns the original client IP.
+
+### Scenario 10: Combined Known Proxies and Networks
+
+Test using both `KnownProxies` and `KnownNetworks` together.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=192.168.1.100
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=127.0.0.0/8,::1/128
+
+dotnet run
+```
+
+**Test with curl:**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "example.com",
+ "remoteIpAddress": "203.0.113.50"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": false,
+ "knownProxies": ["192.168.1.100"],
+ "knownNetworks": ["127.0.0.0/8", "::1/128"]
+ }
+}
+```
+
+Headers are applied because the request comes from localhost (`::1`), which falls within the `::1/128` network even though it's not in the `knownProxies` list.
+
+### Scenario 11: Partial Headers (Proto Only)
+
+Test that each forwarded header is processed independently. Only sending `X-Forwarded-Proto` should update the scheme while leaving host and remoteIpAddress unchanged.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+**Test with curl (only X-Forwarded-Proto):**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "localhost:33333",
+ "remoteIpAddress": "::1"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": true,
+ "knownProxies": [],
+ "knownNetworks": []
+ }
+}
+```
+
+Only the `scheme` changed to `https`. The `host` remains `localhost:33333` and `remoteIpAddress` remains `::1` because those headers weren't sent. Each header is processed independently.
+
+### Scenario 12: IPv4/IPv6 Mismatch
+
+Demonstrates a common misconfiguration where only IPv4 localhost is configured but curl uses IPv6. This scenario shows why you should include both `127.0.0.1` and `::1` in your configuration.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+> **Note:** Only IPv4 `127.0.0.1` is configured, not IPv6 `::1`.
+
+**Test with curl:**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output (if curl uses IPv6):**
+
+```json
+{
+ "processed": {
+ "scheme": "http",
+ "host": "localhost:33333",
+ "remoteIpAddress": "::1"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "203.0.113.50",
+ "xForwardedProto": "https",
+ "xForwardedHost": "example.com"
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": false,
+ "knownProxies": ["127.0.0.1"],
+ "knownNetworks": []
+ }
+}
+```
+
+Headers are **ignored** because the request comes from `::1` (IPv6), but only `127.0.0.1` (IPv4) is in the known proxies list. This is a common gotcha - always include both IPv4 and IPv6 loopback addresses when testing locally, or use CIDR notation like `127.0.0.0/8` and `::1/128`.
+
+> **Tip:** If your output shows headers were applied, curl is using IPv4. The behavior depends on your system's DNS resolution for `localhost`.
+
+## Debug Endpoint
+
+The `/debug/request-info` endpoint is only available in Development environment. It returns:
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "example.com",
+ "remoteIpAddress": "203.0.113.50"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": false,
+ "knownProxies": ["127.0.0.1"],
+ "knownNetworks": []
+ }
+}
+```
+
+| Section | Field | Description |
+|-----------------|-------------------|------------------------------------------------------------------|
+| `processed` | `scheme` | The request scheme after forwarded headers processing |
+| `processed` | `host` | The request host after forwarded headers processing |
+| `processed` | `remoteIpAddress` | The client IP after forwarded headers processing |
+| `rawHeaders` | `xForwardedFor` | Raw `X-Forwarded-For` header (empty if consumed by middleware) |
+| `rawHeaders` | `xForwardedProto` | Raw `X-Forwarded-Proto` header (empty if consumed by middleware) |
+| `rawHeaders` | `xForwardedHost` | Raw `X-Forwarded-Host` header (empty if consumed by middleware) |
+| `configuration` | `enabled` | Whether forwarded headers middleware is enabled |
+| `configuration` | `trustAllProxies` | Whether all proxies are trusted (security warning if true) |
+| `configuration` | `knownProxies` | List of trusted proxy IP addresses |
+| `configuration` | `knownNetworks` | List of trusted CIDR network ranges |
+
+### Key Diagnostic Questions
+
+1. **Were headers applied?** - If `rawHeaders` are empty but `processed` values changed, the middleware consumed and applied them
+2. **Why weren't headers applied?** - If `rawHeaders` still contain values, the middleware didn't trust the caller. Check `knownProxies` and `knownNetworks` in `configuration`
+3. **Is forwarded headers enabled?** - Check `configuration.enabled`
+
+## Cleanup
+
+After testing, clear the environment variables:
+
+**Command Prompt (cmd):**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+```
+
+**PowerShell:**
+
+```powershell
+$env:SERVICECONTROL_FORWARDEDHEADERS_ENABLED = $null
+$env:SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES = $null
+$env:SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES = $null
+$env:SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS = $null
+```
+
+## Quick Reference: Testing Other Instances
+
+### ServiceControl.Audit
+
+```cmd
+cd src\ServiceControl.Audit
+set SERVICECONTROL_AUDIT_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1,::1
+dotnet run
+```
+
+```cmd
+curl -H "X-Forwarded-Proto: https" http://localhost:44444/debug/request-info | json
+```
+
+### ServiceControl.Monitoring
+
+```cmd
+cd src\ServiceControl.Monitoring
+set MONITORING_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1,::1
+dotnet run
+```
+
+```cmd
+curl -H "X-Forwarded-Proto: https" http://localhost:33633/debug/request-info | json
+```
+
+## Unit Tests
+
+Unit tests for the `ForwardedHeadersSettings` configuration class are located at:
+
+```text
+src/ServiceControl.UnitTests/Infrastructure/Settings/ForwardedHeadersSettingsTests.cs
+```
+
+### Running the Tests
+
+```bash
+dotnet test src/ServiceControl.UnitTests/ServiceControl.UnitTests.csproj --filter "FullyQualifiedName~ForwardedHeadersSettingsTests"
+```
+
+### What the Tests Cover
+
+| Test | Purpose |
+|-------------------------------------------------------------------|--------------------------------------------------------------------|
+| `Should_parse_known_proxies_from_comma_separated_list` | Verifies parsing of multiple proxy IPs |
+| `Should_parse_known_proxies_to_ip_addresses` | Verifies `KnownProxies` property returns valid `IPAddress` objects |
+| `Should_ignore_invalid_ip_addresses` | Verifies invalid IPs are filtered out gracefully |
+| `Should_parse_known_networks_from_comma_separated_cidr` | Verifies CIDR notation parsing |
+| `Should_ignore_invalid_network_cidr` | Verifies invalid CIDR entries are filtered |
+| `Should_disable_trust_all_proxies_when_known_proxies_configured` | Verifies auto-disable behavior |
+| `Should_disable_trust_all_proxies_when_known_networks_configured` | Verifies auto-disable behavior |
+| `Should_default_to_enabled` | Verifies default value |
+| `Should_default_to_trust_all_proxies` | Verifies default value |
+| `Should_respect_explicit_disabled_setting` | Verifies explicit configuration |
+| `Should_handle_semicolon_separator_in_proxies` | Tests alternate separator |
+| `Should_trim_whitespace_from_proxy_entries` | Tests whitespace handling |
+
+## Acceptance Tests
+
+Acceptance tests for end-to-end forwarded headers behavior are located at:
+
+```text
+src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/
+src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/
+src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/
+```
+
+Each instance type has identical tests covering all scenarios.
+
+### Running the Tests
+
+```bash
+# ServiceControl (Primary)
+dotnet test src/ServiceControl.AcceptanceTests.RavenDB/ServiceControl.AcceptanceTests.RavenDB.csproj --filter "FullyQualifiedName~ForwardedHeaders"
+
+# ServiceControl.Audit
+dotnet test src/ServiceControl.Audit.AcceptanceTests/ServiceControl.Audit.AcceptanceTests.csproj --filter "FullyQualifiedName~ForwardedHeaders"
+
+# ServiceControl.Monitoring
+dotnet test src/ServiceControl.Monitoring.AcceptanceTests/ServiceControl.Monitoring.AcceptanceTests.csproj --filter "FullyQualifiedName~ForwardedHeaders"
+```
+
+### Scenarios Covered
+
+| Scenario | Test |
+|----------|--------------------------------------------------------|
+| 0 | `When_request_has_no_forwarded_headers` |
+| 1/2 | `When_forwarded_headers_are_sent` |
+| 3 | `When_known_proxies_are_configured` |
+| 4 | `When_known_networks_are_configured` |
+| 5 | `When_unknown_proxy_sends_headers` |
+| 6 | `When_unknown_network_sends_headers` |
+| 7 | `When_forwarded_headers_are_disabled` |
+| 8 | `When_proxy_chain_headers_are_sent` |
+| 9 | `When_proxy_chain_headers_are_sent_with_known_proxies` |
+| 10 | `When_combined_proxies_and_networks_are_configured` |
+| 11 | `When_only_proto_header_is_sent` |
+
+> **Note:** Scenario 12 (IPv4/IPv6 Mismatch) is not covered by acceptance tests because the test server's IP address (IPv4 vs IPv6) cannot be controlled reliably. The "untrusted proxy" behavior is already validated by Scenarios 5 and 6.
+
+## See Also
+
+- [Hosting Guide](hosting-guide.md) - Configuration reference for forwarded headers
+- [Reverse Proxy Testing](reverseproxy-testing.md) - Testing with a real reverse proxy (NGINX)
+- [Testing Architecture](testing-architecture.md) - Overview of testing patterns in this repository
diff --git a/docs/forwarded-headers.md b/docs/forwarded-headers.md
new file mode 100644
index 0000000000..6c6c18fd04
--- /dev/null
+++ b/docs/forwarded-headers.md
@@ -0,0 +1,8 @@
+# Forwarded Headers
+
+See [Forward Headers](https://docs.particular.net/servicecontrol/security/configuration/forward-headers) on the public docs site.
+
+## See Also
+
+- [Forwarded Headers Testing](forward-headers-testing.md) - Test forwarded headers configuration with curl
+- [Reverse Proxy Testing](reverseproxy-testing.md) - Guide for testing with NGINX reverse proxy locally
diff --git a/docs/hosting-guide.md b/docs/hosting-guide.md
new file mode 100644
index 0000000000..76fa37de2f
--- /dev/null
+++ b/docs/hosting-guide.md
@@ -0,0 +1,13 @@
+# ServiceControl Production Hosting Guide
+
+See [ServiceControl Hosting Guide](https://docs.particular.net/servicecontrol/security/hosting-guide) on the public docs site.
+
+## See Also
+
+- [Authentication Configuration](authentication.md) - Detailed authentication setup guide
+- [HTTPS Configuration](https-configuration.md) - Detailed HTTPS setup guide
+- [Forwarded Headers Configuration](forwarded-headers.md) - Forwarded headers reference
+- [Reverse Proxy Testing](reverseproxy-testing.md) - Local testing with NGINX
+- [Authentication Testing](authentication-testing.md) - Testing authentication scenarios
+- [Forwarded Headers Testing](forward-headers-testing.md) - Test forwarded headers configuration with curl
+- [HTTPS Testing](https-testing.md) - Guide for testing HTTPS locally during development
diff --git a/docs/https-configuration.md b/docs/https-configuration.md
new file mode 100644
index 0000000000..afcd26ae95
--- /dev/null
+++ b/docs/https-configuration.md
@@ -0,0 +1,9 @@
+# HTTPS
+
+See [ServiceControl HTTPS Configuration](https://docs.particular.net/servicecontrol/security/configuration/tls) on the public docs site.
+
+## See Also
+
+- [HTTPS Testing](https-testing.md) - Guide for testing HTTPS locally during development
+- [Reverse Proxy Testing](reverseproxy-testing.md) - Testing with NGINX reverse proxy (HSTS, HTTP to HTTPS redirect)
+- [Forwarded Headers Configuration](forwarded-headers.md) - Configure forwarded headers when behind a reverse proxy
diff --git a/docs/https-testing.md b/docs/https-testing.md
new file mode 100644
index 0000000000..9e57ca5484
--- /dev/null
+++ b/docs/https-testing.md
@@ -0,0 +1,262 @@
+# Local Testing with Direct HTTPS
+
+This guide provides scenario-based tests for ServiceControl's direct HTTPS features. Use this to verify Kestrel HTTPS behavior without a reverse proxy.
+
+> **Note:** HTTP to HTTPS redirection (`RedirectHttpToHttps`) is designed for reverse proxy scenarios where the proxy forwards HTTP requests to ServiceControl. When running with direct HTTPS, ServiceControl only binds to a single port (HTTPS). To test HTTP to HTTPS redirection, see [Reverse Proxy Testing](reverseproxy-testing.md).
+
+## Instance Reference
+
+| Instance | Project Directory | Default Port | Environment Variable Prefix | App.config Key Prefix |
+|---------------------------|---------------------------------|--------------|-----------------------------|-------------------------|
+| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | `ServiceControl/` |
+| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | `ServiceControl.Audit/` |
+| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | `Monitoring/` |
+
+> **Note:** Environment variables must include the instance prefix (e.g., `SERVICECONTROL_HTTPS_ENABLED` for the primary instance).
+
+## Prerequisites
+
+- [mkcert](https://github.com/FiloSottile/mkcert) for generating local development certificates
+- ServiceControl built locally (see main README for build instructions)
+- curl (included with Windows 10/11, Git Bash, or WSL)
+
+## Enabling Debug Logs
+
+To enable detailed logging for troubleshooting, set the `LogLevel` environment variable before starting each instance:
+
+```cmd
+rem ServiceControl Primary
+set SERVICECONTROL_LOGLEVEL=Debug
+
+rem ServiceControl.Audit
+set SERVICECONTROL_AUDIT_LOGLEVEL=Debug
+
+rem ServiceControl.Monitoring
+set MONITORING_LOGLEVEL=Debug
+```
+
+**Valid log levels:** `Trace`, `Debug`, `Information` (or `Info`), `Warning` (or `Warn`), `Error`, `Critical` (or `Fatal`), `None` (or `Off`)
+
+Debug logs will show detailed HTTPS configuration and certificate loading information.
+
+### Installing mkcert
+
+**Windows (using Chocolatey):**
+
+```powershell
+choco install mkcert
+```
+
+**Windows (using Scoop):**
+
+```powershell
+scoop install mkcert
+```
+
+**macOS (using Homebrew):**
+
+```bash
+brew install mkcert
+```
+
+**Linux:**
+
+```bash
+# Debian/Ubuntu
+sudo apt install libnss3-tools
+# Then download from https://github.com/FiloSottile/mkcert/releases
+
+# Arch Linux
+sudo pacman -S mkcert
+```
+
+After installing, run `mkcert -install` to install the local CA in your system trust store.
+
+## Setup
+
+### Step 1: Create the Local Development Folder
+
+Create a `.local` folder in the repository root (this folder is gitignored):
+
+```bash
+mkdir .local
+mkdir .local/certs
+```
+
+### Step 2: Generate PFX Certificates
+
+Kestrel requires certificates in PFX format. Use mkcert to generate them:
+
+```bash
+# Install mkcert's root CA (one-time setup)
+mkcert -install
+
+# Navigate to the certs folder
+cd .local/certs
+
+# Generate PFX certificate for localhost
+mkcert -p12-file localhost.pfx -pkcs12 localhost 127.0.0.1 ::1 servicecontrol servicecontrol-audit servicecontrol-monitor
+```
+
+When prompted for a password, you can use an empty password by pressing Enter, or set a password (e.g., `changeit`) and note it for the configuration step.
+
+## Test Scenarios
+
+All scenarios use environment variables for configuration. Run each scenario from the `src/ServiceControl` directory.
+
+### Scenario 1: Basic HTTPS Connectivity
+
+Verify that HTTPS is working with a valid certificate.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_HTTPS_ENABLED=true
+set SERVICECONTROL_HTTPS_CERTIFICATEPATH=C:\path\to\ServiceControl\.local\certs\localhost.pfx
+set SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=changeit
+set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICECONTROL_HTTPS_PORT=
+set SERVICECONTROL_HTTPS_ENABLEHSTS=
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=false
+
+dotnet run
+```
+
+**Test with curl:**
+
+```cmd
+curl --ssl-no-revoke -v https://localhost:33333/api 2>&1 | findstr /C:"HTTP/" /C:"SSL"
+```
+
+> **Note:** The `--ssl-no-revoke` flag is required on Windows because mkcert certificates don't have CRL distribution points, causing `CRYPT_E_NO_REVOCATION_CHECK` errors.
+
+**Expected output:**
+
+```text
+* schannel: SSL/TLS connection renegotiated
+< HTTP/1.1 200 OK
+```
+
+The request succeeds over HTTPS. The exact SSL output varies by curl version and platform, but you should see `HTTP/1.1 200 OK` confirming success.
+
+### Scenario 2: HTTP Disabled (HTTPS Only)
+
+Verify that HTTP requests fail when only HTTPS is enabled.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_HTTPS_ENABLED=true
+set SERVICECONTROL_HTTPS_CERTIFICATEPATH=C:\path\to\ServiceControl\.local\certs\localhost.pfx
+set SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=changeit
+set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICECONTROL_HTTPS_PORT=
+set SERVICECONTROL_HTTPS_ENABLEHSTS=
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=false
+
+dotnet run
+```
+
+**Test with curl (HTTP):**
+
+```cmd
+curl http://localhost:33333/api
+```
+
+**Expected output:**
+
+```text
+curl: (52) Empty reply from server
+```
+
+HTTP requests fail because Kestrel is listening for HTTPS but receives plaintext HTTP, which it cannot process. The server closes the connection without responding.
+
+## HTTPS Configuration Reference
+
+| App.config Key | Environment Variable (Primary) | Default | Description |
+|-------------------------------|----------------------------------------------|------------|------------------------------------------------------|
+| `Https.Enabled` | `SERVICECONTROL_HTTPS_ENABLED` | `false` | Enable Kestrel HTTPS |
+| `Https.CertificatePath` | `SERVICECONTROL_HTTPS_CERTIFICATEPATH` | - | Path to PFX certificate file |
+| `Https.CertificatePassword` | `SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD` | - | Certificate password (empty string for no password) |
+| `Https.RedirectHttpToHttps` | `SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS` | `false` | Redirect HTTP requests to HTTPS (reverse proxy only) |
+| `Https.EnableHsts` | `SERVICECONTROL_HTTPS_ENABLEHSTS` | `false` | Enable HTTP Strict Transport Security |
+| `Https.HstsMaxAgeSeconds` | `SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS` | `31536000` | HSTS max-age (1 year) |
+| `Https.HstsIncludeSubDomains` | `SERVICECONTROL_HTTPS_HSTSINCLUDESUBDOMAINS` | `false` | Include subdomains in HSTS |
+
+> **Note:** For other instances, replace the `SERVICECONTROL_` prefix with the appropriate instance prefix (see Instance Reference table).
+>
+> **Note:** HSTS is not tested locally because ASP.NET Core excludes localhost from HSTS by default (to prevent accidentally caching HSTS during development). HSTS will work correctly in production with non-localhost hostnames.
+
+## Testing Other Instances
+
+The scenarios above use ServiceControl (Primary). To test ServiceControl.Audit or ServiceControl.Monitoring:
+
+1. Use the appropriate environment variable prefix (see Instance Reference above)
+2. Use the corresponding project directory and port
+
+| Instance | Project Directory | Port | Env Var Prefix |
+|---------------------------|---------------------------------|-------|-------------------------|
+| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` |
+| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` |
+| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` |
+
+## Troubleshooting
+
+### Certificate not found
+
+Ensure the `CertificatePath` is an absolute path and the file exists.
+
+### Certificate password incorrect
+
+If you set a password when generating the PFX, ensure it matches `CertificatePassword` in the config.
+
+### Certificate errors in browser/curl
+
+1. Ensure mkcert's root CA is installed: `mkcert -install`
+2. Restart your browser after installing the root CA
+
+### CRYPT_E_NO_REVOCATION_CHECK error in curl
+
+Windows curl fails to check certificate revocation for mkcert certificates because they don't have CRL distribution points. Use the `--ssl-no-revoke` flag:
+
+```cmd
+curl --ssl-no-revoke https://localhost:33333/api
+```
+
+### Port already in use
+
+Ensure no other process is using the ServiceControl ports (33333, 44444, 33633).
+
+## Cleanup
+
+After testing, clear the environment variables:
+
+**Command Prompt (cmd):**
+
+```cmd
+set SERVICECONTROL_HTTPS_ENABLED=
+set SERVICECONTROL_HTTPS_CERTIFICATEPATH=
+set SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=
+set SERVICECONTROL_HTTPS_ENABLEHSTS=
+set SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS=
+set SERVICECONTROL_HTTPS_HSTSINCLUDESUBDOMAINS=
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=
+```
+
+**PowerShell:**
+
+```powershell
+$env:SERVICECONTROL_HTTPS_ENABLED = $null
+$env:SERVICECONTROL_HTTPS_CERTIFICATEPATH = $null
+$env:SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD = $null
+$env:SERVICECONTROL_HTTPS_ENABLEHSTS = $null
+$env:SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS = $null
+$env:SERVICECONTROL_HTTPS_HSTSINCLUDESUBDOMAINS = $null
+$env:SERVICECONTROL_FORWARDEDHEADERS_ENABLED = $null
+```
+
+## See Also
+
+- [Hosting Guide](hosting-guide.md) - Detailed configuration reference for all deployment scenarios
+- [Reverse Proxy Testing](reverseproxy-testing.md) - Testing with a reverse proxy (NGINX)
+- [Forwarded Headers Testing](forward-headers-testing.md) - Testing forwarded headers without a reverse proxy
diff --git a/docs/reverseproxy-testing.md b/docs/reverseproxy-testing.md
new file mode 100644
index 0000000000..121b4b8809
--- /dev/null
+++ b/docs/reverseproxy-testing.md
@@ -0,0 +1,554 @@
+# Local Testing with NGINX Reverse Proxy
+
+This guide provides scenario-based tests for ServiceControl instances behind an NGINX reverse proxy. Use this to verify:
+
+- SSL/TLS termination at the reverse proxy
+- Forwarded headers handling (`X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`)
+- HTTP to HTTPS redirection
+- HSTS (HTTP Strict Transport Security)
+- WebSocket support (SignalR)
+
+## Instance Reference
+
+| Instance | Project Directory | Default Port | Hostname | Environment Variable Prefix |
+|---------------------------|---------------------------------|--------------|------------------------------------|-----------------------------|
+| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `servicecontrol.localhost` | `SERVICECONTROL_` |
+| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `servicecontrol-audit.localhost` | `SERVICECONTROL_AUDIT_` |
+| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `servicecontrol-monitor.localhost` | `MONITORING_` |
+
+## Prerequisites
+
+- [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed and running
+- [mkcert](https://github.com/FiloSottile/mkcert) for generating local development certificates
+- ServiceControl built locally (see main README for build instructions)
+- curl (included with Windows 10/11, Git Bash, or WSL)
+
+## Enabling Debug Logs
+
+To enable detailed logging for troubleshooting, set the `LogLevel` environment variable before starting each instance:
+
+```cmd
+rem ServiceControl Primary
+set SERVICECONTROL_LOGLEVEL=Debug
+
+rem ServiceControl.Audit
+set SERVICECONTROL_AUDIT_LOGLEVEL=Debug
+
+rem ServiceControl.Monitoring
+set MONITORING_LOGLEVEL=Debug
+```
+
+**Valid log levels:** `Trace`, `Debug`, `Information` (or `Info`), `Warning` (or `Warn`), `Error`, `Critical` (or `Fatal`), `None` (or `Off`)
+
+Debug logs will show detailed request processing information including forwarded headers handling and HTTPS redirection.
+
+### Installing mkcert
+
+**Windows (using Chocolatey):**
+
+```cmd
+choco install mkcert
+```
+
+**Windows (using Scoop):**
+
+```cmd
+scoop install mkcert
+```
+
+After installing, run `mkcert -install` to install the local CA in your system trust store.
+
+## Setup
+
+### Step 1: Create the Local Development Folder
+
+Create a `.local` folder in the repository root (this folder is gitignored):
+
+```cmd
+mkdir .local
+mkdir .local\certs
+```
+
+### Step 2: Generate SSL Certificates
+
+Use mkcert to generate trusted local development certificates:
+
+```cmd
+mkcert -install
+cd .local\certs
+mkcert -cert-file local-platform.pem -key-file local-platform-key.pem servicecontrol.localhost servicecontrol-audit.localhost servicecontrol-monitor.localhost localhost
+```
+
+### Step 3: Create Docker Compose Configuration
+
+Create `.local/compose.yml`:
+
+```yaml
+services:
+ reverse-proxy-servicecontrol:
+ image: nginx:alpine
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - ./nginx.conf:/etc/nginx/nginx.conf:ro
+ - ./certs/local-platform.pem:/etc/nginx/certs/local.pem:ro
+ - ./certs/local-platform-key.pem:/etc/nginx/certs/local-key.pem:ro
+```
+
+### Step 4: Create NGINX Configuration
+
+Create `.local/nginx.conf`:
+
+```nginx
+events { worker_connections 1024; }
+
+http {
+ # WebSocket support: set connection to 'upgrade' if Upgrade header present
+ map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+ }
+
+ # Shared SSL Settings
+ ssl_certificate /etc/nginx/certs/local.pem;
+ ssl_certificate_key /etc/nginx/certs/local-key.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_ciphers HIGH:!aNULL:!MD5;
+
+ # ServiceControl (Primary) - HTTPS
+ server {
+ listen 443 ssl;
+ server_name servicecontrol.localhost;
+
+ location / {
+ proxy_pass http://host.docker.internal:33333;
+
+ # WebSocket Support
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+
+ # Forwarded Headers
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ }
+ }
+
+ # ServiceControl (Primary) - HTTP (for testing HTTP-to-HTTPS redirect)
+ server {
+ listen 80;
+ server_name servicecontrol.localhost;
+
+ location / {
+ proxy_pass http://host.docker.internal:33333;
+
+ # WebSocket Support
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+
+ # Forwarded Headers
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ }
+ }
+
+ # ServiceControl.Audit - HTTPS
+ server {
+ listen 443 ssl;
+ server_name servicecontrol-audit.localhost;
+
+ location / {
+ proxy_pass http://host.docker.internal:44444;
+
+ # WebSocket Support
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+
+ # Forwarded Headers
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ }
+ }
+
+ # ServiceControl.Audit - HTTP (for testing HTTP-to-HTTPS redirect)
+ server {
+ listen 80;
+ server_name servicecontrol-audit.localhost;
+
+ location / {
+ proxy_pass http://host.docker.internal:44444;
+
+ # WebSocket Support
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+
+ # Forwarded Headers
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ }
+ }
+
+ # ServiceControl.Monitoring - HTTPS
+ server {
+ listen 443 ssl;
+ server_name servicecontrol-monitor.localhost;
+
+ location / {
+ proxy_pass http://host.docker.internal:33633;
+
+ # WebSocket Support
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+
+ # Forwarded Headers
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ }
+ }
+
+ # ServiceControl.Monitoring - HTTP (for testing HTTP-to-HTTPS redirect)
+ server {
+ listen 80;
+ server_name servicecontrol-monitor.localhost;
+
+ location / {
+ proxy_pass http://host.docker.internal:33633;
+
+ # WebSocket Support
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+
+ # Forwarded Headers
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ }
+ }
+}
+```
+
+### Step 5: Configure Hosts File
+
+Add the following entries to your hosts file (`C:\Windows\System32\drivers\etc\hosts`):
+
+```text
+127.0.0.1 servicecontrol.localhost
+127.0.0.1 servicecontrol-audit.localhost
+127.0.0.1 servicecontrol-monitor.localhost
+```
+
+### Step 6: Start the NGINX Reverse Proxy
+
+From the repository root:
+
+```cmd
+docker compose -f .local/compose.yml up -d
+```
+
+### Step 7: Final Directory Structure
+
+After completing the setup, your `.local` folder should look like:
+
+```text
+.local/
+├── compose.yml
+├── nginx.conf
+└── certs/
+ ├── local-platform.pem
+ └── local-platform-key.pem
+```
+
+## Test Scenarios
+
+> **Important:** ServiceControl must be running before testing. A 502 Bad Gateway error means NGINX cannot reach ServiceControl.
+> **Note:** Use `TRUSTALLPROXIES=true` for local Docker testing. The NGINX container's IP address varies based on Docker's network configuration (e.g., `172.x.x.x`), making it impractical to specify a fixed `KNOWNPROXIES` value.
+
+### Scenario 1: HTTPS Access
+
+Verify that HTTPS is working through the reverse proxy.
+
+**Clear environment variables and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICECONTROL_HTTPS_PORT=
+set SERVICECONTROL_HTTPS_ENABLEHSTS=
+
+cd src\ServiceControl
+dotnet run --no-launch-profile
+```
+
+**Test with curl:**
+
+```cmd
+curl -k -v https://servicecontrol.localhost/api 2>&1 | findstr /C:"HTTP/"
+```
+
+**Expected output:**
+
+```text
+< HTTP/1.1 200 OK
+```
+
+The request succeeds over HTTPS through the NGINX reverse proxy.
+
+### Scenario 2: Forwarded Headers Processing
+
+Verify that forwarded headers are being processed correctly.
+
+**Clear environment variables and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true
+set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICECONTROL_HTTPS_PORT=
+set SERVICECONTROL_HTTPS_ENABLEHSTS=
+
+cd src\ServiceControl
+dotnet run --no-launch-profile
+```
+
+**Test with curl:**
+
+```cmd
+curl -k https://servicecontrol.localhost/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "servicecontrol.localhost",
+ "remoteIpAddress": "172.x.x.x"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": true,
+ "knownProxies": [],
+ "knownNetworks": []
+ }
+}
+```
+
+The key indicators that forwarded headers are working:
+
+- `processed.scheme` is `https` (from `X-Forwarded-Proto`)
+- `processed.host` is `servicecontrol.localhost` (from `X-Forwarded-Host`)
+- `rawHeaders` are empty because the middleware consumed them (trusted proxy)
+
+### Scenario 3: HTTP to HTTPS Redirect
+
+Verify that HTTP requests are redirected to HTTPS.
+
+**Clear environment variables and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true
+set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=true
+set SERVICECONTROL_HTTPS_PORT=443
+set SERVICECONTROL_HTTPS_ENABLEHSTS=
+
+cd src\ServiceControl
+dotnet run --no-launch-profile
+```
+
+**Test with curl:**
+
+```cmd
+curl -v http://servicecontrol.localhost/api 2>&1 | findstr /i location
+```
+
+**Expected output:**
+
+```text
+< Location: https://servicecontrol.localhost/api
+```
+
+HTTP requests are redirected to HTTPS with a 307 (Temporary Redirect) status.
+
+### Scenario 4: HSTS
+
+Verify that the HSTS header is included in HTTPS responses.
+
+> **Note:** HSTS is disabled in Development environment. You must use `--no-launch-profile` to prevent launchSettings.json from overriding it.
+
+**Clear environment variables and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true
+set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICECONTROL_HTTPS_PORT=
+set SERVICECONTROL_HTTPS_ENABLEHSTS=true
+
+cd src\ServiceControl
+dotnet run --environment Production --no-launch-profile
+```
+
+**Test with curl:**
+
+```cmd
+curl -k -v https://servicecontrol.localhost/api 2>&1 | findstr /i strict-transport-security
+```
+
+**Expected output:**
+
+```text
+< Strict-Transport-Security: max-age=31536000
+```
+
+The HSTS header is present with the default max-age of 1 year.
+
+## Testing Other Instances
+
+The scenarios above use ServiceControl (Primary). To test ServiceControl.Audit or ServiceControl.Monitoring:
+
+1. Use the appropriate environment variable prefix (see Configuration Reference below)
+2. Use the corresponding project directory and hostname
+
+| Instance | Project Directory | Hostname | Env Var Prefix |
+|---------------------------|---------------------------------|------------------------------------|-------------------------|
+| ServiceControl (Primary) | `src\ServiceControl` | `servicecontrol.localhost` | `SERVICECONTROL_` |
+| ServiceControl.Audit | `src\ServiceControl.Audit` | `servicecontrol-audit.localhost` | `SERVICECONTROL_AUDIT_` |
+| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | `servicecontrol-monitor.localhost` | `MONITORING_` |
+
+## Configuration Reference
+
+| Environment Variable | Default | Description |
+|---------------------------------------------|------------|---------------------------------------------|
+| `{PREFIX}_FORWARDEDHEADERS_ENABLED` | `true` | Enable forwarded headers processing |
+| `{PREFIX}_FORWARDEDHEADERS_TRUSTALLPROXIES` | `true` | Trust all proxies |
+| `{PREFIX}_FORWARDEDHEADERS_KNOWNPROXIES` | - | Comma-separated list of trusted proxy IPs |
+| `{PREFIX}_FORWARDEDHEADERS_KNOWNNETWORKS` | - | Comma-separated list of trusted CIDR ranges |
+| `{PREFIX}_HTTPS_REDIRECTHTTPTOHTTPS` | `false` | Redirect HTTP to HTTPS |
+| `{PREFIX}_HTTPS_PORT` | - | HTTPS port for redirect |
+| `{PREFIX}_HTTPS_ENABLEHSTS` | `false` | Enable HSTS |
+| `{PREFIX}_HTTPS_HSTSMAXAGESECONDS` | `31536000` | HSTS max-age (1 year) |
+| `{PREFIX}_HTTPS_HSTSINCLUDESUBDOMAINS` | `false` | Include subdomains in HSTS |
+
+Where `{PREFIX}` is:
+
+- `SERVICECONTROL` for ServiceControl (Primary)
+- `SERVICECONTROL_AUDIT` for ServiceControl.Audit
+- `MONITORING` for ServiceControl.Monitoring
+
+## Cleanup
+
+### Stop NGINX
+
+```cmd
+docker compose -f .local/compose.yml down
+```
+
+### Clear Environment Variables
+
+After testing, clear the environment variables:
+
+**Command Prompt (cmd):**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICECONTROL_HTTPS_PORT=
+set SERVICECONTROL_HTTPS_ENABLEHSTS=
+```
+
+**PowerShell:**
+
+```powershell
+$env:SERVICECONTROL_FORWARDEDHEADERS_ENABLED = $null
+$env:SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES = $null
+$env:SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS = $null
+$env:SERVICECONTROL_HTTPS_PORT = $null
+$env:SERVICECONTROL_HTTPS_ENABLEHSTS = $null
+```
+
+### Remove Hosts Entries (Optional)
+
+If you no longer need the hostnames, remove these entries from your hosts file (`C:\Windows\System32\drivers\etc\hosts`):
+
+```text
+127.0.0.1 servicecontrol.localhost
+127.0.0.1 servicecontrol-audit.localhost
+127.0.0.1 servicecontrol-monitor.localhost
+```
+
+## Troubleshooting
+
+### 502 Bad Gateway
+
+This error means NGINX cannot reach ServiceControl. Check:
+
+1. ServiceControl is running (`dotnet run` in the appropriate project directory)
+2. ServiceControl is accessible directly: `curl http://localhost:33333/api`
+3. Docker Desktop is running and `host.docker.internal` resolves correctly
+
+### "Connection refused" errors
+
+Ensure ServiceControl instances are running and listening on the expected ports:
+
+- ServiceControl (Primary): 33333
+- ServiceControl.Audit: 44444
+- ServiceControl.Monitoring: 33633
+
+### Headers not being applied
+
+1. Verify `FORWARDEDHEADERS_ENABLED` is `true`
+2. Verify `FORWARDEDHEADERS_TRUSTALLPROXIES` is `true` (for local Docker testing)
+3. Use the `/debug/request-info` endpoint to check current settings
+
+### Certificate errors in browser
+
+1. Ensure mkcert's root CA is installed: `mkcert -install`
+2. Restart your browser after installing the root CA
+
+### Docker networking issues
+
+If using Docker Desktop on Windows with WSL2:
+
+- Ensure `host.docker.internal` resolves correctly
+- Check that the ServiceControl ports are not blocked by Windows Firewall
+
+### Debug endpoint not available
+
+The `/debug/request-info` endpoint is only available when running in Development environment (the default when using `dotnet run`).
+
+## See Also
+
+- [Hosting Guide](hosting-guide.md) - Configuration reference for all deployment scenarios
+- [Forwarded Headers Testing](forward-headers-testing.md) - Testing forwarded headers without a reverse proxy
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index f3c51d39b4..e81765563d 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -6,7 +6,8 @@
-
+
+
@@ -16,6 +17,8 @@
+
+
@@ -28,6 +31,9 @@
+
+
+
@@ -84,6 +90,7 @@
+
diff --git a/src/ServiceControl.AcceptanceTesting/Cors/CorsAssertions.cs b/src/ServiceControl.AcceptanceTesting/Cors/CorsAssertions.cs
new file mode 100644
index 0000000000..bb942608ef
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTesting/Cors/CorsAssertions.cs
@@ -0,0 +1,161 @@
+namespace ServiceControl.AcceptanceTesting.Cors
+{
+ using System.Net;
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using NUnit.Framework;
+
+ ///
+ /// Shared assertion helpers for CORS acceptance tests.
+ /// Used across all instance types (Primary, Audit, Monitoring).
+ ///
+ public static class CorsAssertions
+ {
+ public const string AllowOriginHeader = "Access-Control-Allow-Origin";
+ public const string AllowCredentialsHeader = "Access-Control-Allow-Credentials";
+ public const string AllowMethodsHeader = "Access-Control-Allow-Methods";
+ public const string AllowHeadersHeader = "Access-Control-Allow-Headers";
+ public const string ExposeHeadersHeader = "Access-Control-Expose-Headers";
+
+ ///
+ /// Sends a preflight OPTIONS request with the Origin header to check CORS policy.
+ ///
+ public static async Task SendPreflightRequest(
+ HttpClient httpClient,
+ string origin,
+ string requestMethod = "GET")
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Options, "/api");
+ request.Headers.Add("Origin", origin);
+ request.Headers.Add("Access-Control-Request-Method", requestMethod);
+
+ return await httpClient.SendAsync(request);
+ }
+
+ ///
+ /// Sends a simple GET request with the Origin header to check CORS response headers.
+ ///
+ public static async Task SendRequestWithOrigin(
+ HttpClient httpClient,
+ string origin,
+ string endpoint = "/api")
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, endpoint);
+ request.Headers.Add("Origin", origin);
+
+ return await httpClient.SendAsync(request);
+ }
+
+ ///
+ /// Asserts that CORS is configured to allow any origin.
+ /// The Access-Control-Allow-Origin header should be "*".
+ ///
+ public static void AssertAllowAnyOrigin(HttpResponseMessage response, string sentOrigin)
+ {
+ Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.Forbidden));
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(response.Headers.Contains(AllowOriginHeader), Is.True,
+ "Response should contain Access-Control-Allow-Origin header");
+
+ var allowOrigin = response.Headers.GetValues(AllowOriginHeader);
+ Assert.That(allowOrigin, Does.Contain("*"),
+ "Access-Control-Allow-Origin should be '*' when AllowAnyOrigin is true");
+
+ // When AllowAnyOrigin is true, AllowCredentials should NOT be set
+ // (browsers reject credentials with wildcard origin)
+ Assert.That(response.Headers.Contains(AllowCredentialsHeader), Is.False,
+ "Access-Control-Allow-Credentials should not be set with wildcard origin");
+ }
+ }
+
+ ///
+ /// Asserts that CORS allows the specific origin that was requested.
+ ///
+ public static void AssertAllowedOrigin(HttpResponseMessage response, string expectedOrigin)
+ {
+ Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.Forbidden));
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(response.Headers.Contains(AllowOriginHeader), Is.True,
+ "Response should contain Access-Control-Allow-Origin header");
+
+ var allowOrigin = response.Headers.GetValues(AllowOriginHeader);
+ Assert.That(allowOrigin, Does.Contain(expectedOrigin),
+ $"Access-Control-Allow-Origin should be '{expectedOrigin}'");
+
+ // When specific origins are configured, AllowCredentials should be true
+ Assert.That(response.Headers.Contains(AllowCredentialsHeader), Is.True,
+ "Access-Control-Allow-Credentials should be set for specific origins");
+
+ var allowCredentials = response.Headers.GetValues(AllowCredentialsHeader);
+ Assert.That(allowCredentials, Does.Contain("true"),
+ "Access-Control-Allow-Credentials should be 'true'");
+ }
+ }
+
+ ///
+ /// Asserts that CORS does not allow the requested origin.
+ /// The Access-Control-Allow-Origin header should NOT be present or should not match the sent origin.
+ ///
+ public static void AssertOriginNotAllowed(HttpResponseMessage response, string sentOrigin)
+ {
+ // The request may succeed (200 OK) but without CORS headers,
+ // browsers will block the response from being read by JavaScript
+ using (Assert.EnterMultipleScope())
+ {
+ if (response.Headers.Contains(AllowOriginHeader))
+ {
+ var allowOrigin = string.Join(", ", response.Headers.GetValues(AllowOriginHeader));
+ Assert.That(allowOrigin, Is.Not.EqualTo(sentOrigin).And.Not.EqualTo("*"),
+ $"Access-Control-Allow-Origin should not allow '{sentOrigin}'");
+ }
+ // If header is not present at all, that's also valid for "not allowed"
+ }
+ }
+
+ ///
+ /// Asserts that CORS is completely disabled (no origins allowed).
+ ///
+ public static void AssertCorsDisabled(HttpResponseMessage response)
+ {
+ // When CORS is disabled, no Access-Control-Allow-Origin header should be present
+ Assert.That(response.Headers.Contains(AllowOriginHeader), Is.False,
+ "Access-Control-Allow-Origin header should not be present when CORS is disabled");
+ }
+
+ ///
+ /// Asserts that the CORS preflight response includes expected methods.
+ ///
+ public static void AssertAllowedMethods(HttpResponseMessage response, params string[] expectedMethods)
+ {
+ Assert.That(response.Headers.Contains(AllowMethodsHeader), Is.True,
+ "Response should contain Access-Control-Allow-Methods header");
+
+ var allowMethods = string.Join(", ", response.Headers.GetValues(AllowMethodsHeader));
+ foreach (var method in expectedMethods)
+ {
+ Assert.That(allowMethods, Does.Contain(method),
+ $"Access-Control-Allow-Methods should contain '{method}'");
+ }
+ }
+
+ ///
+ /// Asserts that the CORS response exposes expected headers.
+ ///
+ public static void AssertExposedHeaders(HttpResponseMessage response, params string[] expectedHeaders)
+ {
+ Assert.That(response.Headers.Contains(ExposeHeadersHeader), Is.True,
+ "Response should contain Access-Control-Expose-Headers header");
+
+ var exposeHeaders = string.Join(", ", response.Headers.GetValues(ExposeHeadersHeader));
+ foreach (var header in expectedHeaders)
+ {
+ Assert.That(exposeHeaders, Does.Contain(header),
+ $"Access-Control-Expose-Headers should contain '{header}'");
+ }
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTesting/Cors/CorsTestConfiguration.cs b/src/ServiceControl.AcceptanceTesting/Cors/CorsTestConfiguration.cs
new file mode 100644
index 0000000000..c4782ee8d9
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTesting/Cors/CorsTestConfiguration.cs
@@ -0,0 +1,80 @@
+namespace ServiceControl.AcceptanceTesting.Cors
+{
+ using System;
+
+ ///
+ /// Helper class to configure CORS environment variables for acceptance tests.
+ /// Environment variables must be set before the ServiceControl instance starts.
+ ///
+ ///
+ /// Creates a new CORS test configuration.
+ ///
+ /// The instance type (determines environment variable prefix)
+ public class CorsTestConfiguration(ServiceControlInstanceType instanceType) : IDisposable
+ {
+ readonly string envVarPrefix = EnvironmentVariablePrefixes.GetPrefix(instanceType);
+ bool disposed;
+
+ ///
+ /// Configures CORS to allow any origin (default behavior for backwards compatibility).
+ ///
+ public CorsTestConfiguration WithAllowAnyOrigin()
+ {
+ SetEnvironmentVariable("CORS_ALLOWANYORIGIN", "true");
+ return this;
+ }
+
+ ///
+ /// Configures CORS to disallow any origin (effectively disabling CORS).
+ ///
+ public CorsTestConfiguration WithDisallowAnyOrigin()
+ {
+ SetEnvironmentVariable("CORS_ALLOWANYORIGIN", "false");
+ return this;
+ }
+
+ ///
+ /// Configures CORS with specific allowed origins.
+ /// Setting allowed origins automatically disables AllowAnyOrigin.
+ ///
+ /// Comma-separated list of allowed origins (e.g., "https://app.example.com,https://admin.example.com")
+ public CorsTestConfiguration WithAllowedOrigins(string origins)
+ {
+ SetEnvironmentVariable("CORS_ALLOWEDORIGINS", origins);
+ return this;
+ }
+
+ ///
+ /// Configures CORS to be completely disabled (no origins allowed).
+ ///
+ public CorsTestConfiguration WithCorsDisabled()
+ {
+ SetEnvironmentVariable("CORS_ALLOWANYORIGIN", "false");
+ // Don't set CORS_ALLOWEDORIGINS - leaves it empty
+ return this;
+ }
+
+ ///
+ /// Clears all CORS environment variables.
+ /// Called automatically on Dispose.
+ ///
+ public void ClearConfiguration()
+ {
+ ClearEnvironmentVariable("CORS_ALLOWANYORIGIN");
+ ClearEnvironmentVariable("CORS_ALLOWEDORIGINS");
+ }
+
+ void SetEnvironmentVariable(string name, string value) => Environment.SetEnvironmentVariable(envVarPrefix + name, value);
+
+ void ClearEnvironmentVariable(string name) => Environment.SetEnvironmentVariable(envVarPrefix + name, null);
+
+ public void Dispose()
+ {
+ if (!disposed)
+ {
+ ClearConfiguration();
+ disposed = true;
+ }
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTesting/EnvironmentVariablePrefixes.cs b/src/ServiceControl.AcceptanceTesting/EnvironmentVariablePrefixes.cs
new file mode 100644
index 0000000000..c1f48c90b8
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTesting/EnvironmentVariablePrefixes.cs
@@ -0,0 +1,18 @@
+namespace ServiceControl.AcceptanceTesting
+{
+ using System;
+
+ ///
+ /// Provides environment variable prefixes for each ServiceControl instance type.
+ ///
+ public static class EnvironmentVariablePrefixes
+ {
+ public static string GetPrefix(ServiceControlInstanceType instanceType) => instanceType switch
+ {
+ ServiceControlInstanceType.Primary => "SERVICECONTROL_",
+ ServiceControlInstanceType.Audit => "SERVICECONTROL_AUDIT_",
+ ServiceControlInstanceType.Monitoring => "MONITORING_",
+ _ => throw new ArgumentOutOfRangeException(nameof(instanceType))
+ };
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersAssertions.cs b/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersAssertions.cs
new file mode 100644
index 0000000000..7e5be41afe
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersAssertions.cs
@@ -0,0 +1,292 @@
+namespace ServiceControl.AcceptanceTesting.ForwardedHeaders
+{
+ using System.Net.Http;
+ using System.Text.Json;
+ using System.Threading.Tasks;
+ using NUnit.Framework;
+
+ ///
+ /// Shared assertion helpers for forwarded headers acceptance tests.
+ /// Used across all instance types (Primary, Audit, Monitoring).
+ ///
+ public static class ForwardedHeadersAssertions
+ {
+ ///
+ /// Fetches request info from the debug endpoint with optional custom forwarded headers.
+ ///
+ public static async Task GetRequestInfo(
+ HttpClient httpClient,
+ JsonSerializerOptions serializerOptions,
+ string xForwardedFor = null,
+ string xForwardedProto = null,
+ string xForwardedHost = null,
+ string testRemoteIp = null)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, "/debug/request-info");
+
+ if (!string.IsNullOrEmpty(xForwardedFor))
+ {
+ request.Headers.Add("X-Forwarded-For", xForwardedFor);
+ }
+ if (!string.IsNullOrEmpty(xForwardedProto))
+ {
+ request.Headers.Add("X-Forwarded-Proto", xForwardedProto);
+ }
+ if (!string.IsNullOrEmpty(xForwardedHost))
+ {
+ request.Headers.Add("X-Forwarded-Host", xForwardedHost);
+ }
+ if (!string.IsNullOrEmpty(testRemoteIp))
+ {
+ request.Headers.Add("X-Test-Remote-IP", testRemoteIp);
+ }
+
+ var response = await httpClient.SendAsync(request);
+ _ = response.EnsureSuccessStatusCode();
+
+ var content = await response.Content.ReadAsStringAsync();
+ return JsonSerializer.Deserialize(content, serializerOptions);
+ }
+
+ ///
+ /// Direct Access (No Proxy)
+ /// When no forwarded headers are sent, the request values should remain unchanged.
+ ///
+ public static void AssertDirectAccessWithNoForwardedHeaders(RequestInfoResponse requestInfo)
+ {
+ Assert.That(requestInfo, Is.Not.Null);
+
+ using (Assert.EnterMultipleScope())
+ {
+ // Processed values should reflect the direct request (no proxy transformation)
+ Assert.That(requestInfo.Processed.Scheme, Is.EqualTo("http"));
+ Assert.That(requestInfo.Processed.Host, Is.Not.Null.And.Not.Empty);
+
+ // Raw headers should be empty since no forwarded headers were sent
+ Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.Empty);
+ Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.Empty);
+ Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.Empty);
+
+ // Default configuration: enabled with trust all proxies
+ Assert.That(requestInfo.Configuration.Enabled, Is.True);
+ Assert.That(requestInfo.Configuration.TrustAllProxies, Is.True);
+ }
+ }
+
+ ///
+ /// Default Behavior with Headers (TrustAllProxies = true)
+ /// When forwarded headers are sent and all proxies are trusted, headers should be applied.
+ ///
+ public static void AssertHeadersAppliedWhenTrustAllProxies(
+ RequestInfoResponse requestInfo,
+ string expectedScheme,
+ string expectedHost,
+ string expectedRemoteIp)
+ {
+ Assert.That(requestInfo, Is.Not.Null);
+
+ using (Assert.EnterMultipleScope())
+ {
+ // Processed values should reflect the forwarded headers
+ Assert.That(requestInfo.Processed.Scheme, Is.EqualTo(expectedScheme));
+ Assert.That(requestInfo.Processed.Host, Is.EqualTo(expectedHost));
+ Assert.That(requestInfo.Processed.RemoteIpAddress, Is.EqualTo(expectedRemoteIp));
+
+ // Raw headers should be empty because middleware consumed them
+ Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.Empty);
+ Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.Empty);
+ Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.Empty);
+
+ // Configuration should show trust all proxies
+ Assert.That(requestInfo.Configuration.Enabled, Is.True);
+ Assert.That(requestInfo.Configuration.TrustAllProxies, Is.True);
+ }
+ }
+
+ ///
+ /// Partial Headers (Proto Only)
+ /// When only X-Forwarded-Proto is sent, only scheme should change.
+ ///
+ public static void AssertPartialHeadersApplied(
+ RequestInfoResponse requestInfo,
+ string expectedScheme)
+ {
+ Assert.That(requestInfo, Is.Not.Null);
+
+ using (Assert.EnterMultipleScope())
+ {
+ // Only scheme should be changed
+ Assert.That(requestInfo.Processed.Scheme, Is.EqualTo(expectedScheme));
+
+ // Host should remain original (not changed to a forwarded value)
+ // In test environment this will be the test server host, not a forwarded host like "example.com"
+ Assert.That(requestInfo.Processed.Host, Is.Not.Null.And.Not.Empty);
+ }
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(requestInfo.Processed.Host, Does.Not.Contain("example.com"));
+
+ // RemoteIpAddress should NOT be a forwarded IP (203.0.113.50)
+ // In test server environment it may be null, localhost, or machine-specific
+ Assert.That(requestInfo.Processed.RemoteIpAddress, Is.Null.Or.Not.EqualTo("203.0.113.50"));
+
+ // Configuration should show trust all proxies
+ Assert.That(requestInfo.Configuration.Enabled, Is.True);
+ Assert.That(requestInfo.Configuration.TrustAllProxies, Is.True);
+ }
+ }
+
+ ///
+ /// Proxy Chain (Multiple X-Forwarded-For Values)
+ /// When TrustAllProxies is true and multiple IPs are in X-Forwarded-For,
+ /// the original client IP (first in the chain) should be returned.
+ ///
+ public static void AssertProxyChainProcessedWithTrustAllProxies(
+ RequestInfoResponse requestInfo,
+ string expectedOriginalClientIp,
+ string expectedScheme,
+ string expectedHost)
+ {
+ Assert.That(requestInfo, Is.Not.Null);
+
+ using (Assert.EnterMultipleScope())
+ {
+ // When TrustAllProxies=true, ForwardLimit=null, so middleware processes all IPs
+ // and returns the original client IP (first in the chain)
+ Assert.That(requestInfo.Processed.Scheme, Is.EqualTo(expectedScheme));
+ Assert.That(requestInfo.Processed.Host, Is.EqualTo(expectedHost));
+ Assert.That(requestInfo.Processed.RemoteIpAddress, Is.EqualTo(expectedOriginalClientIp));
+
+ // Raw headers should be empty because middleware consumed them
+ Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.Empty);
+ Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.Empty);
+ Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.Empty);
+
+ // Configuration should show trust all proxies
+ Assert.That(requestInfo.Configuration.Enabled, Is.True);
+ Assert.That(requestInfo.Configuration.TrustAllProxies, Is.True);
+ }
+ }
+
+ ///
+ /// Headers Applied with Known Proxies/Networks
+ /// When the caller IP matches KnownProxies or KnownNetworks, headers should be applied.
+ ///
+ public static void AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ RequestInfoResponse requestInfo,
+ string expectedScheme,
+ string expectedHost,
+ string expectedRemoteIp)
+ {
+ Assert.That(requestInfo, Is.Not.Null);
+
+ using (Assert.EnterMultipleScope())
+ {
+ // Headers should be applied because caller is trusted
+ Assert.That(requestInfo.Processed.Scheme, Is.EqualTo(expectedScheme));
+ Assert.That(requestInfo.Processed.Host, Is.EqualTo(expectedHost));
+ Assert.That(requestInfo.Processed.RemoteIpAddress, Is.EqualTo(expectedRemoteIp));
+
+ // Raw headers should be empty because middleware consumed them
+ Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.Empty);
+ Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.Empty);
+ Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.Empty);
+
+ // Configuration should show TrustAllProxies=false (auto-disabled when proxies/networks configured)
+ Assert.That(requestInfo.Configuration.Enabled, Is.True);
+ Assert.That(requestInfo.Configuration.TrustAllProxies, Is.False);
+ }
+ }
+
+ ///
+ /// Headers Ignored when Proxy/Network Not Trusted
+ /// When the caller IP does NOT match KnownProxies or KnownNetworks, headers should be ignored.
+ ///
+ public static void AssertHeadersIgnoredWhenProxyNotTrusted(
+ RequestInfoResponse requestInfo,
+ string sentXForwardedFor,
+ string sentXForwardedProto,
+ string sentXForwardedHost)
+ {
+ Assert.That(requestInfo, Is.Not.Null);
+
+ using (Assert.EnterMultipleScope())
+ {
+ // Headers should NOT be applied - values should remain unchanged from direct request
+ Assert.That(requestInfo.Processed.Scheme, Is.EqualTo("http"));
+ // Host should remain the test server host, not the forwarded host
+ Assert.That(requestInfo.Processed.Host, Does.Not.Contain("example.com"));
+
+ // Raw headers should still contain the sent values (not consumed by middleware)
+ Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.EqualTo(sentXForwardedFor));
+ Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.EqualTo(sentXForwardedProto));
+ Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.EqualTo(sentXForwardedHost));
+
+ // Configuration should show TrustAllProxies=false
+ Assert.That(requestInfo.Configuration.Enabled, Is.True);
+ Assert.That(requestInfo.Configuration.TrustAllProxies, Is.False);
+ }
+ }
+
+ ///
+ /// Forwarded Headers Disabled
+ /// When forwarded headers processing is disabled, headers should be ignored regardless of trust.
+ ///
+ public static void AssertHeadersIgnoredWhenDisabled(
+ RequestInfoResponse requestInfo,
+ string sentXForwardedFor,
+ string sentXForwardedProto,
+ string sentXForwardedHost)
+ {
+ Assert.That(requestInfo, Is.Not.Null);
+
+ using (Assert.EnterMultipleScope())
+ {
+ // Headers should NOT be applied - values should remain unchanged
+ Assert.That(requestInfo.Processed.Scheme, Is.EqualTo("http"));
+ Assert.That(requestInfo.Processed.Host, Does.Not.Contain("example.com"));
+
+ // Raw headers should still contain the sent values (middleware disabled)
+ Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.EqualTo(sentXForwardedFor));
+ Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.EqualTo(sentXForwardedProto));
+ Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.EqualTo(sentXForwardedHost));
+
+ // Configuration should show Enabled=false
+ Assert.That(requestInfo.Configuration.Enabled, Is.False);
+ }
+ }
+
+ ///
+ /// Proxy Chain with ForwardLimit=1 (Known Proxies)
+ /// When TrustAllProxies=false, ForwardLimit=1, so only the last proxy IP is processed.
+ ///
+ public static void AssertProxyChainWithForwardLimitOne(
+ RequestInfoResponse requestInfo,
+ string expectedLastProxyIp,
+ string expectedScheme,
+ string expectedHost,
+ string expectedRemainingForwardedFor)
+ {
+ Assert.That(requestInfo, Is.Not.Null);
+
+ using (Assert.EnterMultipleScope())
+ {
+ // When TrustAllProxies=false, ForwardLimit=1, so only last IP is processed
+ Assert.That(requestInfo.Processed.Scheme, Is.EqualTo(expectedScheme));
+ Assert.That(requestInfo.Processed.Host, Is.EqualTo(expectedHost));
+ Assert.That(requestInfo.Processed.RemoteIpAddress, Is.EqualTo(expectedLastProxyIp));
+
+ // X-Forwarded-For should contain remaining IPs (not fully consumed)
+ Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.EqualTo(expectedRemainingForwardedFor));
+ // Proto and Host are fully consumed
+ Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.Empty);
+ Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.Empty);
+
+ // Configuration should show TrustAllProxies=false
+ Assert.That(requestInfo.Configuration.Enabled, Is.True);
+ Assert.That(requestInfo.Configuration.TrustAllProxies, Is.False);
+ }
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersTestConfiguration.cs b/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersTestConfiguration.cs
new file mode 100644
index 0000000000..1078da876a
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersTestConfiguration.cs
@@ -0,0 +1,109 @@
+namespace ServiceControl.AcceptanceTesting.ForwardedHeaders
+{
+ using System;
+
+ ///
+ /// Helper class to configure ForwardedHeaders environment variables for acceptance tests.
+ /// Environment variables must be set before the ServiceControl instance starts.
+ ///
+ ///
+ /// Creates a new forwarded headers test configuration.
+ ///
+ /// The instance type (determines environment variable prefix)
+ public class ForwardedHeadersTestConfiguration(ServiceControlInstanceType instanceType) : IDisposable
+ {
+ readonly string envVarPrefix = EnvironmentVariablePrefixes.GetPrefix(instanceType);
+ bool disposed;
+
+ ///
+ /// Configures forwarded headers to be disabled.
+ ///
+ public ForwardedHeadersTestConfiguration WithForwardedHeadersDisabled()
+ {
+ SetEnvironmentVariable("FORWARDEDHEADERS_ENABLED", "false");
+ return this;
+ }
+
+ ///
+ /// Configures forwarded headers to trust all proxies (default behavior).
+ ///
+ public ForwardedHeadersTestConfiguration WithTrustAllProxies()
+ {
+ SetEnvironmentVariable("FORWARDEDHEADERS_ENABLED", "true");
+ SetEnvironmentVariable("FORWARDEDHEADERS_TRUSTALLPROXIES", "true");
+ return this;
+ }
+
+ ///
+ /// Configures forwarded headers with specific known proxies.
+ /// Setting known proxies automatically disables TrustAllProxies.
+ ///
+ /// Comma-separated list of trusted proxy IP addresses (e.g., "127.0.0.1,::1")
+ public ForwardedHeadersTestConfiguration WithKnownProxies(string proxies)
+ {
+ SetEnvironmentVariable("FORWARDEDHEADERS_ENABLED", "true");
+ SetEnvironmentVariable("FORWARDEDHEADERS_KNOWNPROXIES", proxies);
+ return this;
+ }
+
+ ///
+ /// Configures forwarded headers with specific known networks.
+ /// Setting known networks automatically disables TrustAllProxies.
+ ///
+ /// Comma-separated list of trusted CIDR networks (e.g., "127.0.0.0/8,::1/128")
+ public ForwardedHeadersTestConfiguration WithKnownNetworks(string networks)
+ {
+ SetEnvironmentVariable("FORWARDEDHEADERS_ENABLED", "true");
+ SetEnvironmentVariable("FORWARDEDHEADERS_KNOWNNETWORKS", networks);
+ return this;
+ }
+
+ ///
+ /// Configures forwarded headers with both known proxies and networks.
+ ///
+ /// Comma-separated list of trusted proxy IP addresses
+ /// Comma-separated list of trusted CIDR networks
+ public ForwardedHeadersTestConfiguration WithKnownProxiesAndNetworks(string proxies, string networks)
+ {
+ SetEnvironmentVariable("FORWARDEDHEADERS_ENABLED", "true");
+ SetEnvironmentVariable("FORWARDEDHEADERS_KNOWNPROXIES", proxies);
+ SetEnvironmentVariable("FORWARDEDHEADERS_KNOWNNETWORKS", networks);
+ return this;
+ }
+
+ ///
+ /// Applies the configuration by ensuring environment variables are set.
+ /// This should be called before the ServiceControl instance starts.
+ ///
+ public void Apply()
+ {
+ // Configuration is already applied via the With* methods
+ // This method exists for explicit apply semantics if needed
+ }
+
+ ///
+ /// Clears all forwarded headers environment variables.
+ /// Called automatically on Dispose.
+ ///
+ public void ClearConfiguration()
+ {
+ ClearEnvironmentVariable("FORWARDEDHEADERS_ENABLED");
+ ClearEnvironmentVariable("FORWARDEDHEADERS_TRUSTALLPROXIES");
+ ClearEnvironmentVariable("FORWARDEDHEADERS_KNOWNPROXIES");
+ ClearEnvironmentVariable("FORWARDEDHEADERS_KNOWNNETWORKS");
+ }
+
+ void SetEnvironmentVariable(string name, string value) => Environment.SetEnvironmentVariable(envVarPrefix + name, value);
+
+ void ClearEnvironmentVariable(string name) => Environment.SetEnvironmentVariable(envVarPrefix + name, null);
+
+ public void Dispose()
+ {
+ if (!disposed)
+ {
+ ClearConfiguration();
+ disposed = true;
+ }
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/RequestInfoResponse.cs b/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/RequestInfoResponse.cs
new file mode 100644
index 0000000000..e8c8148536
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/RequestInfoResponse.cs
@@ -0,0 +1,60 @@
+namespace ServiceControl.AcceptanceTesting.ForwardedHeaders
+{
+ using System.Text.Json.Serialization;
+
+ ///
+ /// Response DTO for the /debug/request-info endpoint.
+ /// Used by forwarded headers acceptance tests to verify request processing.
+ /// Shared across all instance types (Primary, Audit, Monitoring).
+ ///
+ public class RequestInfoResponse
+ {
+ [JsonPropertyName("processed")]
+ public ProcessedInfo Processed { get; set; }
+
+ [JsonPropertyName("rawHeaders")]
+ public RawHeadersInfo RawHeaders { get; set; }
+
+ [JsonPropertyName("configuration")]
+ public ConfigurationInfo Configuration { get; set; }
+ }
+
+ public class ProcessedInfo
+ {
+ [JsonPropertyName("scheme")]
+ public string Scheme { get; set; }
+
+ [JsonPropertyName("host")]
+ public string Host { get; set; }
+
+ [JsonPropertyName("remoteIpAddress")]
+ public string RemoteIpAddress { get; set; }
+ }
+
+ public class RawHeadersInfo
+ {
+ [JsonPropertyName("xForwardedFor")]
+ public string XForwardedFor { get; set; }
+
+ [JsonPropertyName("xForwardedProto")]
+ public string XForwardedProto { get; set; }
+
+ [JsonPropertyName("xForwardedHost")]
+ public string XForwardedHost { get; set; }
+ }
+
+ public class ConfigurationInfo
+ {
+ [JsonPropertyName("enabled")]
+ public bool Enabled { get; set; }
+
+ [JsonPropertyName("trustAllProxies")]
+ public bool TrustAllProxies { get; set; }
+
+ [JsonPropertyName("knownProxies")]
+ public string[] KnownProxies { get; set; }
+
+ [JsonPropertyName("knownNetworks")]
+ public string[] KnownNetworks { get; set; }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTesting/Https/HttpsAssertions.cs b/src/ServiceControl.AcceptanceTesting/Https/HttpsAssertions.cs
new file mode 100644
index 0000000000..e6f38b0ef7
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTesting/Https/HttpsAssertions.cs
@@ -0,0 +1,79 @@
+namespace ServiceControl.AcceptanceTesting.Https
+{
+ using System.Net;
+ using System.Net.Http;
+ using NUnit.Framework;
+
+ ///
+ /// Shared assertion helpers for HTTPS acceptance tests.
+ /// Used across all instance types (Primary, Audit, Monitoring).
+ ///
+ public static class HttpsAssertions
+ {
+ public const string StrictTransportSecurityHeader = "Strict-Transport-Security";
+ public const string LocationHeader = "Location";
+
+ ///
+ /// Asserts that the response is a redirect to HTTPS.
+ ///
+ public static void AssertHttpsRedirect(HttpResponseMessage response, int? expectedPort = null)
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.RedirectKeepVerb).Or.EqualTo(HttpStatusCode.Redirect),
+ "Response should be a redirect (307 or 302)");
+
+ Assert.That(response.Headers.Location, Is.Not.Null,
+ "Response should contain Location header");
+ }
+
+ var locationUri = response.Headers.Location;
+ Assert.That(locationUri.Scheme, Is.EqualTo("https"),
+ "Redirect Location should use HTTPS scheme");
+
+ if (expectedPort.HasValue)
+ {
+ Assert.That(locationUri.Port, Is.EqualTo(expectedPort.Value),
+ $"Redirect Location should use port {expectedPort.Value}");
+ }
+ }
+
+ ///
+ /// Asserts that the response is NOT a redirect (for when HTTPS redirect is disabled).
+ ///
+ public static void AssertNoHttpsRedirect(HttpResponseMessage response)
+ {
+ Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.RedirectKeepVerb).And.Not.EqualTo(HttpStatusCode.Redirect),
+ "Response should not be a redirect when HTTPS redirect is disabled");
+ }
+
+ ///
+ /// Asserts that the response contains the HSTS header with expected values.
+ ///
+ public static void AssertHstsHeader(HttpResponseMessage response, int expectedMaxAge = 31536000, bool expectIncludeSubDomains = false)
+ {
+ Assert.That(response.Headers.Contains(StrictTransportSecurityHeader), Is.True,
+ "Response should contain Strict-Transport-Security header");
+
+ var hstsValue = string.Join("; ", response.Headers.GetValues(StrictTransportSecurityHeader));
+
+ Assert.That(hstsValue, Does.Contain($"max-age={expectedMaxAge}"),
+ $"HSTS header should contain max-age={expectedMaxAge}");
+
+ if (expectIncludeSubDomains)
+ {
+ Assert.That(hstsValue, Does.Contain("includeSubDomains"),
+ "HSTS header should contain includeSubDomains");
+ }
+ }
+
+ ///
+ /// Asserts that the response does NOT contain the HSTS header.
+ ///
+ public static void AssertNoHstsHeader(HttpResponseMessage response)
+ {
+ Assert.That(response.Headers.Contains(StrictTransportSecurityHeader), Is.False,
+ "Response should not contain Strict-Transport-Security header when HSTS is disabled");
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTesting/Https/HttpsTestConfiguration.cs b/src/ServiceControl.AcceptanceTesting/Https/HttpsTestConfiguration.cs
new file mode 100644
index 0000000000..08e5a8ca4a
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTesting/Https/HttpsTestConfiguration.cs
@@ -0,0 +1,118 @@
+namespace ServiceControl.AcceptanceTesting.Https
+{
+ using System;
+
+ ///
+ /// Helper class to configure HTTPS environment variables for acceptance tests.
+ /// Environment variables must be set before the ServiceControl instance starts.
+ ///
+ ///
+ /// Creates a new HTTPS test configuration.
+ ///
+ /// The instance type (determines environment variable prefix)
+ public class HttpsTestConfiguration(ServiceControlInstanceType instanceType) : IDisposable
+ {
+ readonly string envVarPrefix = EnvironmentVariablePrefixes.GetPrefix(instanceType);
+ bool disposed;
+
+ ///
+ /// Configures HTTPS redirect to be enabled.
+ /// When enabled, HTTP requests will be redirected to HTTPS.
+ ///
+ public HttpsTestConfiguration WithRedirectHttpToHttps()
+ {
+ SetEnvironmentVariable("HTTPS_REDIRECTHTTPTOHTTPS", "true");
+ return this;
+ }
+
+ ///
+ /// Configures HTTPS redirect to be disabled (default behavior).
+ ///
+ public HttpsTestConfiguration WithRedirectHttpToHttpsDisabled()
+ {
+ SetEnvironmentVariable("HTTPS_REDIRECTHTTPTOHTTPS", "false");
+ return this;
+ }
+
+ ///
+ /// Configures the port to redirect HTTPS requests to.
+ /// Only used when RedirectHttpToHttps is true.
+ ///
+ /// The HTTPS port to redirect to
+ public HttpsTestConfiguration WithHttpsPort(int port)
+ {
+ SetEnvironmentVariable("HTTPS_PORT", port.ToString());
+ return this;
+ }
+
+ ///
+ /// Configures HSTS (HTTP Strict Transport Security) to be enabled.
+ /// HSTS instructs browsers to only access the site via HTTPS.
+ /// Note: HSTS only applies in non-development environments.
+ ///
+ public HttpsTestConfiguration WithHstsEnabled()
+ {
+ SetEnvironmentVariable("HTTPS_ENABLEHSTS", "true");
+ return this;
+ }
+
+ ///
+ /// Configures HSTS to be disabled (default behavior).
+ ///
+ public HttpsTestConfiguration WithHstsDisabled()
+ {
+ SetEnvironmentVariable("HTTPS_ENABLEHSTS", "false");
+ return this;
+ }
+
+ ///
+ /// Configures the max-age value for the HSTS header in seconds.
+ /// Only used when EnableHsts is true.
+ ///
+ /// The max-age value in seconds
+ public HttpsTestConfiguration WithHstsMaxAge(int seconds)
+ {
+ SetEnvironmentVariable("HTTPS_HSTSMAXAGESECONDS", seconds.ToString());
+ return this;
+ }
+
+ ///
+ /// Configures whether subdomains should be included in the HSTS policy.
+ /// Only used when EnableHsts is true.
+ ///
+ public HttpsTestConfiguration WithHstsIncludeSubDomains()
+ {
+ SetEnvironmentVariable("HTTPS_HSTSINCLUDESUBDOMAINS", "true");
+ return this;
+ }
+
+ ///
+ /// Clears all HTTPS environment variables.
+ /// Called automatically on Dispose.
+ ///
+ public void ClearConfiguration()
+ {
+ ClearEnvironmentVariable("HTTPS_ENABLED");
+ ClearEnvironmentVariable("HTTPS_CERTIFICATEPATH");
+ ClearEnvironmentVariable("HTTPS_CERTIFICATEPASSWORD");
+ ClearEnvironmentVariable("HTTPS_REDIRECTHTTPTOHTTPS");
+ ClearEnvironmentVariable("HTTPS_PORT");
+ ClearEnvironmentVariable("HTTPS_ENABLEHSTS");
+ ClearEnvironmentVariable("HTTPS_HSTSMAXAGESECONDS");
+ ClearEnvironmentVariable("HTTPS_HSTSINCLUDESUBDOMAINS");
+ }
+
+ void SetEnvironmentVariable(string name, string value) => Environment.SetEnvironmentVariable(envVarPrefix + name, value);
+
+ void ClearEnvironmentVariable(string name) => Environment.SetEnvironmentVariable(envVarPrefix + name, null);
+
+ public void Dispose()
+ {
+ if (!disposed)
+ {
+ ClearConfiguration();
+ disposed = true;
+ }
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTesting/OpenIdConnect/MockOidcServer.cs b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/MockOidcServer.cs
new file mode 100644
index 0000000000..92779fff4d
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/MockOidcServer.cs
@@ -0,0 +1,270 @@
+namespace ServiceControl.AcceptanceTesting.OpenIdConnect
+{
+ using System;
+ using System.Collections.Generic;
+ using System.IdentityModel.Tokens.Jwt;
+ using System.Net;
+ using System.Security.Claims;
+ using System.Security.Cryptography;
+ using System.Text;
+ using System.Text.Json;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.IdentityModel.Tokens;
+
+ ///
+ /// A mock OpenID Connect server for acceptance testing.
+ /// Provides OIDC discovery endpoints and can generate valid JWT tokens.
+ ///
+ public class MockOidcServer : IDisposable
+ {
+ readonly HttpListener listener;
+ readonly RSA rsaKey;
+ readonly RsaSecurityKey securityKey;
+ readonly string keyId;
+ readonly CancellationTokenSource cts = new();
+ bool disposed;
+
+ public string Authority { get; }
+ public string Audience { get; }
+ public int Port { get; }
+
+ public MockOidcServer(int port = 0, string audience = "api://test-audience")
+ {
+ // Use a random port if 0 is specified
+ Port = port == 0 ? GetAvailablePort() : port;
+ Authority = $"http://localhost:{Port}";
+ Audience = audience;
+
+ // Generate RSA key pair for signing tokens
+ rsaKey = RSA.Create(2048);
+ keyId = Guid.NewGuid().ToString("N")[..16];
+ securityKey = new RsaSecurityKey(rsaKey) { KeyId = keyId };
+
+ listener = new HttpListener();
+ listener.Prefixes.Add($"{Authority}/");
+ }
+
+ static int GetAvailablePort()
+ {
+ // Find an available port by binding to port 0
+ var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ public void Start()
+ {
+ listener.Start();
+ _ = Task.Run(async () =>
+ {
+ while (!cts.Token.IsCancellationRequested)
+ {
+ try
+ {
+ var context = await listener.GetContextAsync();
+ _ = Task.Run(() => HandleRequest(context));
+ }
+ catch (HttpListenerException) when (cts.Token.IsCancellationRequested)
+ {
+ // Expected when stopping
+ break;
+ }
+ catch (ObjectDisposedException)
+ {
+ // Expected when stopping
+ break;
+ }
+ }
+ });
+ }
+
+ void HandleRequest(HttpListenerContext context)
+ {
+ var path = context.Request.Url?.AbsolutePath ?? "";
+ var response = context.Response;
+
+ try
+ {
+ if (path == "/.well-known/openid-configuration")
+ {
+ ServeDiscoveryDocument(response);
+ }
+ else if (path is "/.well-known/jwks" or "/jwks")
+ {
+ ServeJwks(response);
+ }
+ else
+ {
+ response.StatusCode = 404;
+ response.Close();
+ }
+ }
+ catch
+ {
+ response.StatusCode = 500;
+ response.Close();
+ }
+ }
+
+ void ServeDiscoveryDocument(HttpListenerResponse response)
+ {
+ var discovery = new Dictionary
+ {
+ ["issuer"] = Authority,
+ ["authorization_endpoint"] = $"{Authority}/authorize",
+ ["token_endpoint"] = $"{Authority}/token",
+ ["jwks_uri"] = $"{Authority}/.well-known/jwks",
+ ["response_types_supported"] = new[] { "code", "token", "id_token" },
+ ["subject_types_supported"] = new[] { "public" },
+ ["id_token_signing_alg_values_supported"] = new[] { "RS256" },
+ ["scopes_supported"] = new[] { "openid", "profile", "email" },
+ ["token_endpoint_auth_methods_supported"] = new[] { "client_secret_basic", "client_secret_post" },
+ ["claims_supported"] = new[] { "sub", "iss", "aud", "exp", "iat", "name", "email" }
+ };
+
+ var json = JsonSerializer.Serialize(discovery);
+ WriteJsonResponse(response, json);
+ }
+
+ void ServeJwks(HttpListenerResponse response)
+ {
+ var parameters = rsaKey.ExportParameters(false);
+
+ var jwk = new Dictionary
+ {
+ ["kty"] = "RSA",
+ ["use"] = "sig",
+ ["kid"] = keyId,
+ ["alg"] = "RS256",
+ ["n"] = Base64UrlEncode(parameters.Modulus),
+ ["e"] = Base64UrlEncode(parameters.Exponent)
+ };
+
+ var jwks = new Dictionary
+ {
+ ["keys"] = new[] { jwk }
+ };
+
+ var json = JsonSerializer.Serialize(jwks);
+ WriteJsonResponse(response, json);
+ }
+
+ static void WriteJsonResponse(HttpListenerResponse response, string json)
+ {
+ response.ContentType = "application/json";
+ response.StatusCode = 200;
+ var buffer = Encoding.UTF8.GetBytes(json);
+ response.ContentLength64 = buffer.Length;
+ response.OutputStream.Write(buffer, 0, buffer.Length);
+ response.Close();
+ }
+
+ static string Base64UrlEncode(byte[] data)
+ {
+ return Convert.ToBase64String(data)
+ .TrimEnd('=')
+ .Replace('+', '-')
+ .Replace('/', '_');
+ }
+
+ ///
+ /// Generates a valid JWT token signed by this mock server.
+ ///
+ /// The subject (sub) claim
+ /// Token lifetime
+ /// Additional claims to include
+ /// A signed JWT token string
+ public string GenerateToken(
+ string subject = "test-user",
+ TimeSpan? expiresIn = null,
+ IEnumerable additionalClaims = null)
+ {
+ var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256);
+
+ var claims = new List
+ {
+ new(JwtRegisteredClaimNames.Sub, subject),
+ new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
+ };
+
+ if (additionalClaims != null)
+ {
+ claims.AddRange(additionalClaims);
+ }
+
+ var token = new JwtSecurityToken(
+ issuer: Authority,
+ audience: Audience,
+ claims: claims,
+ notBefore: DateTime.UtcNow,
+ expires: DateTime.UtcNow.Add(expiresIn ?? TimeSpan.FromHours(1)),
+ signingCredentials: credentials);
+
+ return new JwtSecurityTokenHandler().WriteToken(token);
+ }
+
+ ///
+ /// Generates an expired JWT token for testing token expiration.
+ ///
+ public string GenerateExpiredToken(string subject = "test-user")
+ {
+ var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256);
+
+ var claims = new List
+ {
+ new(JwtRegisteredClaimNames.Sub, subject),
+ new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
+ };
+
+ var token = new JwtSecurityToken(
+ issuer: Authority,
+ audience: Audience,
+ claims: claims,
+ notBefore: DateTime.UtcNow.AddHours(-2),
+ expires: DateTime.UtcNow.AddHours(-1),
+ signingCredentials: credentials);
+
+ return new JwtSecurityTokenHandler().WriteToken(token);
+ }
+
+ ///
+ /// Generates a token with an invalid audience.
+ ///
+ public string GenerateTokenWithWrongAudience(string subject = "test-user")
+ {
+ var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256);
+
+ var claims = new List
+ {
+ new(JwtRegisteredClaimNames.Sub, subject),
+ new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
+ };
+
+ var token = new JwtSecurityToken(
+ issuer: Authority,
+ audience: "wrong-audience",
+ claims: claims,
+ notBefore: DateTime.UtcNow,
+ expires: DateTime.UtcNow.AddHours(1),
+ signingCredentials: credentials);
+
+ return new JwtSecurityTokenHandler().WriteToken(token);
+ }
+
+ public void Dispose()
+ {
+ if (!disposed)
+ {
+ cts.Cancel();
+ listener.Stop();
+ listener.Close();
+ rsaKey.Dispose();
+ cts.Dispose();
+ disposed = true;
+ }
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectAssertions.cs b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectAssertions.cs
new file mode 100644
index 0000000000..80b343e0cb
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectAssertions.cs
@@ -0,0 +1,209 @@
+namespace ServiceControl.AcceptanceTesting.OpenIdConnect
+{
+ using System.Net;
+ using System.Net.Http;
+ using System.Net.Http.Headers;
+ using System.Text.Json;
+ using System.Threading.Tasks;
+ using NUnit.Framework;
+
+ ///
+ /// Shared assertion helpers for OpenID Connect acceptance tests.
+ /// Used across all instance types (Primary, Audit, Monitoring).
+ ///
+ public static class OpenIdConnectAssertions
+ {
+ public const string AuthorizationHeader = "Authorization";
+ public const string WwwAuthenticateHeader = "WWW-Authenticate";
+ public const string XTokenExpiredHeader = "X-Token-Expired";
+
+ ///
+ /// Asserts that the response indicates successful authentication/authorization.
+ ///
+ public static void AssertAuthenticated(HttpResponseMessage response)
+ {
+ Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.Unauthorized).And.Not.EqualTo(HttpStatusCode.Forbidden),
+ "Response should not be 401 Unauthorized or 403 Forbidden when properly authenticated");
+ }
+
+ ///
+ /// Asserts that the response indicates authentication is required (401 Unauthorized).
+ ///
+ public static void AssertUnauthorized(HttpResponseMessage response)
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized),
+ "Response should be 401 Unauthorized when authentication is required but not provided");
+ }
+
+ ///
+ /// Asserts that the response indicates forbidden access (403 Forbidden).
+ ///
+ public static void AssertForbidden(HttpResponseMessage response)
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden),
+ "Response should be 403 Forbidden when the user lacks required permissions");
+ }
+
+ ///
+ /// Asserts that the response does not require authentication (for when auth is disabled).
+ ///
+ public static void AssertNoAuthenticationRequired(HttpResponseMessage response)
+ {
+ Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.Unauthorized),
+ "Response should not require authentication when OpenID Connect is disabled");
+ }
+
+ ///
+ /// Asserts that the response contains the WWW-Authenticate header with Bearer scheme.
+ ///
+ public static void AssertWwwAuthenticateHeader(HttpResponseMessage response)
+ {
+ Assert.That(response.Headers.WwwAuthenticate, Is.Not.Empty,
+ "Response should contain WWW-Authenticate header");
+
+ var hasBearer = false;
+ foreach (var header in response.Headers.WwwAuthenticate)
+ {
+ if (header.Scheme == "Bearer")
+ {
+ hasBearer = true;
+ break;
+ }
+ }
+
+ Assert.That(hasBearer, Is.True,
+ "WWW-Authenticate header should specify Bearer scheme");
+ }
+
+ ///
+ /// Asserts that the response body contains the expected error response format.
+ ///
+ public static async Task AssertAuthErrorResponse(HttpResponseMessage response, string expectedError = "unauthorized")
+ {
+ var content = await response.Content.ReadAsStringAsync();
+ Assert.That(content, Is.Not.Null.And.Not.Empty, "Response should have a body");
+
+ var jsonDoc = JsonDocument.Parse(content);
+ var root = jsonDoc.RootElement;
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(root.TryGetProperty("error", out var errorProperty), Is.True,
+ "Response should contain 'error' property");
+ Assert.That(errorProperty.GetString(), Is.EqualTo(expectedError),
+ $"Error should be '{expectedError}'");
+
+ Assert.That(root.TryGetProperty("message", out var messageProperty), Is.True,
+ "Response should contain 'message' property");
+ Assert.That(messageProperty.GetString(), Is.Not.Null.And.Not.Empty,
+ "Message should not be empty");
+ }
+ }
+
+ ///
+ /// Asserts that the authentication configuration endpoint returns expected values.
+ ///
+ public static async Task AssertAuthConfigurationResponse(
+ HttpResponseMessage response,
+ bool expectedEnabled,
+ string expectedClientId = null,
+ string expectedAuthority = null,
+ string expectedAudience = null,
+ string expectedApiScopes = null)
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
+ "Authentication configuration endpoint should return 200 OK");
+
+ var content = await response.Content.ReadAsStringAsync();
+ var jsonDoc = JsonDocument.Parse(content);
+ var root = jsonDoc.RootElement;
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(root.TryGetProperty("enabled", out var enabledProperty), Is.True,
+ "Response should contain 'enabled' property");
+ Assert.That(enabledProperty.GetBoolean(), Is.EqualTo(expectedEnabled),
+ $"'enabled' should be {expectedEnabled}");
+ }
+
+ // Note: API uses snake_case JSON serialization (client_id, api_scopes)
+ if (expectedClientId != null)
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(root.TryGetProperty("client_id", out var clientIdProperty), Is.True,
+ "Response should contain 'client_id' property");
+ Assert.That(clientIdProperty.GetString(), Is.EqualTo(expectedClientId),
+ $"'client_id' should be '{expectedClientId}'");
+ }
+ }
+
+ if (expectedAuthority != null)
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(root.TryGetProperty("authority", out var authorityProperty), Is.True,
+ "Response should contain 'authority' property");
+ Assert.That(authorityProperty.GetString(), Is.EqualTo(expectedAuthority),
+ $"'authority' should be '{expectedAuthority}'");
+ }
+ }
+
+ if (expectedAudience != null)
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(root.TryGetProperty("audience", out var audienceProperty), Is.True,
+ "Response should contain 'audience' property");
+ Assert.That(audienceProperty.GetString(), Is.EqualTo(expectedAudience),
+ $"'audience' should be '{expectedAudience}'");
+ }
+ }
+
+ if (expectedApiScopes != null)
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(root.TryGetProperty("api_scopes", out var apiScopesProperty), Is.True,
+ "Response should contain 'api_scopes' property");
+ Assert.That(apiScopesProperty.GetString(), Is.EqualTo(expectedApiScopes),
+ $"'api_scopes' should be '{expectedApiScopes}'");
+ }
+ }
+ }
+
+ ///
+ /// Creates an Authorization header with a Bearer token.
+ ///
+ public static AuthenticationHeaderValue CreateBearerToken(string token)
+ {
+ return new AuthenticationHeaderValue("Bearer", token);
+ }
+
+ ///
+ /// Sends a request with a Bearer token.
+ ///
+ public static async Task SendRequestWithBearerToken(
+ HttpClient client,
+ HttpMethod method,
+ string path,
+ string token)
+ {
+ using var request = new HttpRequestMessage(method, path);
+ request.Headers.Authorization = CreateBearerToken(token);
+ return await client.SendAsync(request);
+ }
+
+ ///
+ /// Sends a request without any authentication.
+ ///
+ public static async Task SendRequestWithoutAuth(
+ HttpClient client,
+ HttpMethod method,
+ string path)
+ {
+ using var request = new HttpRequestMessage(method, path);
+ return await client.SendAsync(request);
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectTestConfiguration.cs b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectTestConfiguration.cs
new file mode 100644
index 0000000000..fa79e783b6
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectTestConfiguration.cs
@@ -0,0 +1,183 @@
+namespace ServiceControl.AcceptanceTesting.OpenIdConnect
+{
+ using System;
+
+ ///
+ /// Helper class to configure OpenID Connect environment variables for acceptance tests.
+ /// Environment variables must be set before the ServiceControl instance starts.
+ ///
+ ///
+ /// Creates a new OpenID Connect test configuration.
+ ///
+ /// The instance type (determines environment variable prefix)
+ public class OpenIdConnectTestConfiguration(ServiceControlInstanceType instanceType) : IDisposable
+ {
+ readonly string envVarPrefix = EnvironmentVariablePrefixes.GetPrefix(instanceType);
+ bool disposed;
+
+ ///
+ /// Enables OpenID Connect authentication.
+ /// When enabled, all API endpoints require a valid JWT Bearer token unless marked with [AllowAnonymous].
+ ///
+ public OpenIdConnectTestConfiguration WithAuthenticationEnabled()
+ {
+ SetEnvironmentVariable("AUTHENTICATION_ENABLED", "true");
+ return this;
+ }
+
+ ///
+ /// Disables OpenID Connect authentication (default behavior).
+ ///
+ public OpenIdConnectTestConfiguration WithAuthenticationDisabled()
+ {
+ SetEnvironmentVariable("AUTHENTICATION_ENABLED", "false");
+ return this;
+ }
+
+ ///
+ /// Disables settings validation. This allows testing with placeholder/fake OIDC settings.
+ /// Should only be used in test scenarios where a real OIDC provider is not available.
+ ///
+ public OpenIdConnectTestConfiguration WithConfigurationValidationDisabled()
+ {
+ SetEnvironmentVariable("VALIDATECONFIG", "false");
+ return this;
+ }
+
+ ///
+ /// Configures the OpenID Connect authority URL (issuer).
+ ///
+ /// The authority URL (e.g., https://login.microsoftonline.com/{tenant-id}/v2.0)
+ public OpenIdConnectTestConfiguration WithAuthority(string authority)
+ {
+ SetEnvironmentVariable("AUTHENTICATION_AUTHORITY", authority);
+ return this;
+ }
+
+ ///
+ /// Configures the expected audience claim in the JWT token.
+ ///
+ /// The audience identifier
+ public OpenIdConnectTestConfiguration WithAudience(string audience)
+ {
+ SetEnvironmentVariable("AUTHENTICATION_AUDIENCE", audience);
+ return this;
+ }
+
+ ///
+ /// Configures whether to validate the token's issuer.
+ /// Default is true. Set to false only for testing purposes.
+ ///
+ public OpenIdConnectTestConfiguration WithValidateIssuer(bool validate)
+ {
+ SetEnvironmentVariable("AUTHENTICATION_VALIDATEISSUER", validate.ToString().ToLowerInvariant());
+ return this;
+ }
+
+ ///
+ /// Configures whether to validate the token's audience.
+ /// Default is true. Set to false only for testing purposes.
+ ///
+ public OpenIdConnectTestConfiguration WithValidateAudience(bool validate)
+ {
+ SetEnvironmentVariable("AUTHENTICATION_VALIDATEAUDIENCE", validate.ToString().ToLowerInvariant());
+ return this;
+ }
+
+ ///
+ /// Configures whether to validate the token's lifetime.
+ /// Default is true. Set to false only for testing purposes.
+ ///
+ public OpenIdConnectTestConfiguration WithValidateLifetime(bool validate)
+ {
+ SetEnvironmentVariable("AUTHENTICATION_VALIDATELIFETIME", validate.ToString().ToLowerInvariant());
+ return this;
+ }
+
+ ///
+ /// Configures whether to validate the token's signing key.
+ /// Default is true. Set to false only for testing purposes.
+ ///
+ public OpenIdConnectTestConfiguration WithValidateIssuerSigningKey(bool validate)
+ {
+ SetEnvironmentVariable("AUTHENTICATION_VALIDATEISSUERSIGNINGKEY", validate.ToString().ToLowerInvariant());
+ return this;
+ }
+
+ ///
+ /// Configures whether to require HTTPS for metadata retrieval.
+ /// Default is true. Set to false for local development with HTTP identity providers.
+ ///
+ public OpenIdConnectTestConfiguration WithRequireHttpsMetadata(bool require)
+ {
+ SetEnvironmentVariable("AUTHENTICATION_REQUIREHTTPSMETADATA", require.ToString().ToLowerInvariant());
+ return this;
+ }
+
+ ///
+ /// Configures the OAuth client ID that ServicePulse should use.
+ /// Required on the primary ServiceControl instance when authentication is enabled.
+ ///
+ /// The client ID
+ public OpenIdConnectTestConfiguration WithServicePulseClientId(string clientId)
+ {
+ SetEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_CLIENTID", clientId);
+ return this;
+ }
+
+ ///
+ /// Configures the API scopes that ServicePulse should request.
+ /// Required on the primary ServiceControl instance when authentication is enabled.
+ ///
+ /// Space-separated list of API scopes
+ public OpenIdConnectTestConfiguration WithServicePulseApiScopes(string scopes)
+ {
+ SetEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_APISCOPES", scopes);
+ return this;
+ }
+
+ ///
+ /// Configures an optional override for the authority URL that ServicePulse should use.
+ /// If not specified, ServicePulse uses the main Authority value.
+ ///
+ /// The ServicePulse authority URL
+ public OpenIdConnectTestConfiguration WithServicePulseAuthority(string authority)
+ {
+ SetEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_AUTHORITY", authority);
+ return this;
+ }
+
+ ///
+ /// Clears all OpenID Connect environment variables.
+ /// Called automatically on Dispose.
+ ///
+ public void ClearConfiguration()
+ {
+ ClearEnvironmentVariable("AUTHENTICATION_ENABLED");
+ ClearEnvironmentVariable("AUTHENTICATION_AUTHORITY");
+ ClearEnvironmentVariable("AUTHENTICATION_AUDIENCE");
+ ClearEnvironmentVariable("AUTHENTICATION_VALIDATEISSUER");
+ ClearEnvironmentVariable("AUTHENTICATION_VALIDATEAUDIENCE");
+ ClearEnvironmentVariable("AUTHENTICATION_VALIDATELIFETIME");
+ ClearEnvironmentVariable("AUTHENTICATION_VALIDATEISSUERSIGNINGKEY");
+ ClearEnvironmentVariable("AUTHENTICATION_REQUIREHTTPSMETADATA");
+ ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_CLIENTID");
+ ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_APISCOPES");
+ ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_AUTHORITY");
+ ClearEnvironmentVariable("VALIDATECONFIG");
+ }
+
+ void SetEnvironmentVariable(string name, string value) => Environment.SetEnvironmentVariable(envVarPrefix + name, value);
+
+ void ClearEnvironmentVariable(string name) => Environment.SetEnvironmentVariable(envVarPrefix + name, null);
+
+ public void Dispose()
+ {
+ if (!disposed)
+ {
+ ClearConfiguration();
+ disposed = true;
+ }
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTesting/ServiceControl.AcceptanceTesting.csproj b/src/ServiceControl.AcceptanceTesting/ServiceControl.AcceptanceTesting.csproj
index fb3544fabd..8c2bb516d0 100644
--- a/src/ServiceControl.AcceptanceTesting/ServiceControl.AcceptanceTesting.csproj
+++ b/src/ServiceControl.AcceptanceTesting/ServiceControl.AcceptanceTesting.csproj
@@ -10,9 +10,11 @@
+
+
\ No newline at end of file
diff --git a/src/ServiceControl.AcceptanceTesting/ServiceControlInstanceType.cs b/src/ServiceControl.AcceptanceTesting/ServiceControlInstanceType.cs
new file mode 100644
index 0000000000..1b02334e69
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTesting/ServiceControlInstanceType.cs
@@ -0,0 +1,12 @@
+namespace ServiceControl.AcceptanceTesting
+{
+ ///
+ /// Identifies the ServiceControl instance type for environment variable prefix selection.
+ ///
+ public enum ServiceControlInstanceType
+ {
+ Primary,
+ Audit,
+ Monitoring
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/Cors/When_cors_allows_any_origin.cs b/src/ServiceControl.AcceptanceTests/Security/Cors/When_cors_allows_any_origin.cs
new file mode 100644
index 0000000000..65d042cdc3
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/Cors/When_cors_allows_any_origin.cs
@@ -0,0 +1,92 @@
+namespace ServiceControl.AcceptanceTests.Security.Cors
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Cors;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Default CORS Behavior (AllowAnyOrigin = true)
+ /// When AllowAnyOrigin is true (default for backwards compatibility), requests from any origin should be allowed.
+ /// The Access-Control-Allow-Origin header should be "*".
+ ///
+ class When_cors_allows_any_origin : AcceptanceTest
+ {
+ CorsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureCors() =>
+ // Default behavior - allow any origin
+ configuration = new CorsTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithAllowAnyOrigin();
+
+ [TearDown]
+ public void CleanupCors() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_return_wildcard_access_control_allow_origin_header()
+ {
+ HttpResponseMessage response = null;
+ const string testOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: testOrigin,
+ endpoint: "/api");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertAllowAnyOrigin(response, testOrigin);
+ }
+
+ [Test]
+ public async Task Should_return_expected_allowed_methods()
+ {
+ HttpResponseMessage response = null;
+ const string testOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendPreflightRequest(
+ HttpClient,
+ origin: testOrigin,
+ requestMethod: "POST");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertAllowedMethods(response, "POST", "GET", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD");
+ }
+
+ [Test]
+ public async Task Should_return_expected_exposed_headers()
+ {
+ HttpResponseMessage response = null;
+ const string testOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: testOrigin,
+ endpoint: "/api");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertExposedHeaders(response, "ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/Cors/When_cors_is_disabled.cs b/src/ServiceControl.AcceptanceTests/Security/Cors/When_cors_is_disabled.cs
new file mode 100644
index 0000000000..6afcb8ff35
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/Cors/When_cors_is_disabled.cs
@@ -0,0 +1,72 @@
+namespace ServiceControl.AcceptanceTests.Security.Cors
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Cors;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// CORS Disabled
+ /// When AllowAnyOrigin is false and no AllowedOrigins are configured, CORS is effectively disabled.
+ /// No Access-Control-Allow-Origin header should be returned for any origin.
+ ///
+ class When_cors_is_disabled : AcceptanceTest
+ {
+ CorsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureCors() =>
+ // Disable CORS by setting AllowAnyOrigin to false and not configuring any origins
+ configuration = new CorsTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithCorsDisabled();
+
+ [TearDown]
+ public void CleanupCors() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_not_return_access_control_allow_origin_header()
+ {
+ HttpResponseMessage response = null;
+ const string testOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: testOrigin,
+ endpoint: "/api");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertCorsDisabled(response);
+ }
+
+ [Test]
+ public async Task Preflight_request_should_not_return_cors_headers()
+ {
+ HttpResponseMessage response = null;
+ const string testOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendPreflightRequest(
+ HttpClient,
+ origin: testOrigin,
+ requestMethod: "POST");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertCorsDisabled(response);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/Cors/When_request_from_allowed_origin.cs b/src/ServiceControl.AcceptanceTests/Security/Cors/When_request_from_allowed_origin.cs
new file mode 100644
index 0000000000..af92c27737
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/Cors/When_request_from_allowed_origin.cs
@@ -0,0 +1,93 @@
+namespace ServiceControl.AcceptanceTests.Security.Cors
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Cors;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Request from Allowed Origin
+ /// When a request comes from an origin that is in the AllowedOrigins list,
+ /// the response should include the Access-Control-Allow-Origin header with that specific origin.
+ ///
+ class When_request_from_allowed_origin : AcceptanceTest
+ {
+ CorsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureCors() =>
+ // Configure specific allowed origins
+ configuration = new CorsTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithAllowedOrigins("https://app.example.com,https://admin.example.com");
+
+ [TearDown]
+ public void CleanupCors() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_return_matching_origin_in_access_control_allow_origin_header()
+ {
+ HttpResponseMessage response = null;
+ const string allowedOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: allowedOrigin,
+ endpoint: "/api");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertAllowedOrigin(response, allowedOrigin);
+ }
+
+ [Test]
+ public async Task Should_allow_second_configured_origin()
+ {
+ HttpResponseMessage response = null;
+ const string allowedOrigin = "https://admin.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: allowedOrigin,
+ endpoint: "/api");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertAllowedOrigin(response, allowedOrigin);
+ }
+
+ [Test]
+ public async Task Preflight_request_should_return_correct_cors_headers()
+ {
+ HttpResponseMessage response = null;
+ const string allowedOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendPreflightRequest(
+ HttpClient,
+ origin: allowedOrigin,
+ requestMethod: "POST");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertAllowedOrigin(response, allowedOrigin);
+ CorsAssertions.AssertAllowedMethods(response, "POST", "GET", "PUT", "DELETE");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/Cors/When_request_from_disallowed_origin.cs b/src/ServiceControl.AcceptanceTests/Security/Cors/When_request_from_disallowed_origin.cs
new file mode 100644
index 0000000000..bd1a974c93
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/Cors/When_request_from_disallowed_origin.cs
@@ -0,0 +1,135 @@
+namespace ServiceControl.AcceptanceTests.Security.Cors
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Cors;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Request from Disallowed Origin
+ /// When a request comes from an origin that is NOT in the AllowedOrigins list,
+ /// the response should NOT include an Access-Control-Allow-Origin header that matches the request origin.
+ ///
+ class When_request_from_disallowed_origin : AcceptanceTest
+ {
+ CorsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureCors() =>
+ // Configure specific allowed origins - the test origin won't be in this list
+ configuration = new CorsTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithAllowedOrigins("https://app.example.com,https://admin.example.com");
+
+ [TearDown]
+ public void CleanupCors() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_not_return_access_control_allow_origin_header_for_disallowed_origin()
+ {
+ HttpResponseMessage response = null;
+ const string disallowedOrigin = "https://malicious.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: disallowedOrigin,
+ endpoint: "/api");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertOriginNotAllowed(response, disallowedOrigin);
+ }
+
+ [Test]
+ public async Task Should_not_allow_origin_with_different_scheme()
+ {
+ HttpResponseMessage response = null;
+ // http:// instead of https://
+ const string disallowedOrigin = "http://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: disallowedOrigin,
+ endpoint: "/api");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertOriginNotAllowed(response, disallowedOrigin);
+ }
+
+ [Test]
+ public async Task Should_not_allow_origin_with_different_port()
+ {
+ HttpResponseMessage response = null;
+ // Different port
+ const string disallowedOrigin = "https://app.example.com:8080";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: disallowedOrigin,
+ endpoint: "/api");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertOriginNotAllowed(response, disallowedOrigin);
+ }
+
+ [Test]
+ public async Task Should_not_allow_subdomain_when_parent_domain_is_configured()
+ {
+ HttpResponseMessage response = null;
+ // Subdomain of allowed origin
+ const string disallowedOrigin = "https://sub.app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: disallowedOrigin,
+ endpoint: "/api");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertOriginNotAllowed(response, disallowedOrigin);
+ }
+
+ [Test]
+ public async Task Preflight_request_should_not_allow_disallowed_origin()
+ {
+ HttpResponseMessage response = null;
+ const string disallowedOrigin = "https://malicious.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendPreflightRequest(
+ HttpClient,
+ origin: disallowedOrigin,
+ requestMethod: "POST");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertOriginNotAllowed(response, disallowedOrigin);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs
new file mode 100644
index 0000000000..99bac6024f
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs
@@ -0,0 +1,63 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Combined Known Proxies and Networks from local-forward-headers-testing.md
+ /// When both KnownProxies and KnownNetworks are configured, matching either grants trust.
+ ///
+ class When_combined_proxies_and_networks_are_configured : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Configure both proxies (that don't match localhost) and networks (that include localhost)
+ // The localhost should match via the networks, proving OR logic
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithKnownProxiesAndNetworks("192.168.1.100", "127.0.0.0/8,::1/128");
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Headers_should_be_applied_when_caller_matches_network_but_not_proxy()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+
+ using (Assert.EnterMultipleScope())
+ {
+ // Verify configuration shows both proxies and networks
+ Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("192.168.1.100"));
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8").Or.Contain("::1/128"));
+ }
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs
new file mode 100644
index 0000000000..132e3d7c6e
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs
@@ -0,0 +1,55 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Forwarded Headers Disabled
+ /// When forwarded headers processing is disabled, headers should be ignored regardless of trust.
+ ///
+ class When_forwarded_headers_are_disabled : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Disable forwarded headers processing entirely
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithForwardedHeadersDisabled();
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Headers_should_be_ignored_when_disabled()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersIgnoredWhenDisabled(
+ requestInfo,
+ sentXForwardedFor: "203.0.113.50",
+ sentXForwardedProto: "https",
+ sentXForwardedHost: "example.com");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs
new file mode 100644
index 0000000000..e886f667e4
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs
@@ -0,0 +1,44 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Default Behavior with Headers
+ /// When forwarded headers are sent and TrustAllProxies is true (default), headers should be applied.
+ ///
+ class When_forwarded_headers_are_sent : AcceptanceTest
+ {
+ [Test]
+ public async Task Headers_should_be_applied_when_trust_all_proxies()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWhenTrustAllProxies(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs
new file mode 100644
index 0000000000..4623ba4869
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs
@@ -0,0 +1,58 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Known Networks (CIDR)
+ /// When KnownNetworks are configured and the caller IP falls within, headers should be applied.
+ ///
+ class When_known_networks_are_configured : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Configure known networks to include localhost CIDR ranges (test server uses localhost)
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithKnownNetworks("127.0.0.0/8,::1/128");
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Headers_should_be_applied_when_caller_matches_known_network()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+
+ // Verify configuration shows known networks
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8").Or.Contain("::1/128"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs
new file mode 100644
index 0000000000..44676d9bbd
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs
@@ -0,0 +1,58 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Known Proxies Only
+ /// When KnownProxies are configured and the caller IP matches, headers should be applied.
+ ///
+ class When_known_proxies_are_configured : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Configure known proxies to include localhost addresses (test server uses localhost)
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithKnownProxies("127.0.0.1,::1");
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Headers_should_be_applied_when_caller_matches_known_proxy()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+
+ // Verify configuration shows known proxies
+ Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("127.0.0.1").Or.Contain("::1"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs
new file mode 100644
index 0000000000..ee0386b618
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs
@@ -0,0 +1,38 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Partial Headers (Proto Only)
+ /// When only X-Forwarded-Proto is sent, only scheme should change.
+ ///
+ class When_only_proto_header_is_sent : AcceptanceTest
+ {
+ [Test]
+ public async Task Only_scheme_should_be_changed()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedProto: "https");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertPartialHeadersApplied(requestInfo, expectedScheme: "https");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs
new file mode 100644
index 0000000000..323df5b2ab
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs
@@ -0,0 +1,48 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Proxy Chain (Multiple X-Forwarded-For Values)
+ /// When TrustAllProxies is true and X-Forwarded-For contains multiple IPs (proxy chain),
+ /// the original client IP (first in the chain) should be returned.
+ ///
+ class When_proxy_chain_headers_are_sent : AcceptanceTest
+ {
+ [Test]
+ public async Task Original_client_ip_should_be_returned_when_trust_all_proxies()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // Simulate a proxy chain: client -> proxy1 -> proxy2 -> ServiceControl
+ // X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1
+ // Expected: 203.0.113.50 (original client)
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertProxyChainProcessedWithTrustAllProxies(
+ requestInfo,
+ expectedOriginalClientIp: "203.0.113.50",
+ expectedScheme: "https",
+ expectedHost: "example.com");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs
new file mode 100644
index 0000000000..ee8e57ed5f
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs
@@ -0,0 +1,59 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Proxy Chain with Known Proxies (ForwardLimit=1)
+ /// When TrustAllProxies=false (known proxies configured), ForwardLimit=1, so only the last proxy IP is processed.
+ ///
+ class When_proxy_chain_headers_are_sent_with_known_proxies : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Configure known proxies to include localhost (test server uses localhost)
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithKnownProxies("127.0.0.1,::1");
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Only_last_proxy_ip_should_be_processed_when_forward_limit_is_one()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // Simulate a proxy chain: client -> proxy1 -> proxy2 -> ServiceControl
+ // X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1
+ // Expected with ForwardLimit=1: 192.168.1.1 (last proxy in chain)
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertProxyChainWithForwardLimitOne(
+ requestInfo,
+ expectedLastProxyIp: "192.168.1.1",
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemainingForwardedFor: "203.0.113.50,10.0.0.1");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs
new file mode 100644
index 0000000000..0b5c6b7e3a
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs
@@ -0,0 +1,40 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Direct Access (No Proxy)
+ /// When no forwarded headers are sent, the request values should remain unchanged.
+ ///
+ class When_request_has_no_forwarded_headers : AcceptanceTest
+ {
+ [Test]
+ public async Task Request_values_should_remain_unchanged()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ var result = await this.TryGet("/debug/request-info");
+ if (result.HasResult)
+ {
+ requestInfo = result.Item;
+ return true;
+ }
+ return false;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertDirectAccessWithNoForwardedHeaders(requestInfo);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs
new file mode 100644
index 0000000000..c90101f3c4
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs
@@ -0,0 +1,63 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Unknown Network Rejected
+ /// When KnownNetworks are configured but the caller IP does NOT fall within, headers should be ignored.
+ ///
+ class When_unknown_network_sends_headers : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Configure known networks that do NOT include localhost (test server uses localhost)
+ // This should cause headers to be ignored
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithKnownNetworks("10.0.0.0/8,192.168.0.0/16");
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Headers_should_be_ignored_when_caller_not_in_known_networks()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // Simulate request from IP 203.0.113.1 (not in known networks)
+ // The known networks are 10.0.0.0/8 and 192.168.0.0/16, so this IP should be rejected
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com",
+ testRemoteIp: "203.0.113.1");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersIgnoredWhenProxyNotTrusted(
+ requestInfo,
+ sentXForwardedFor: "203.0.113.50",
+ sentXForwardedProto: "https",
+ sentXForwardedHost: "example.com");
+
+ // Verify configuration shows the networks (203.0.113.1 is NOT in these networks)
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("10.0.0.0/8"));
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("192.168.0.0/16"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs
new file mode 100644
index 0000000000..bef1f36cd9
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs
@@ -0,0 +1,62 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Unknown Proxy Rejected
+ /// When KnownProxies are configured but the caller IP does NOT match, headers should be ignored.
+ ///
+ class When_unknown_proxy_sends_headers : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Configure a known proxy that does NOT match localhost (test server uses localhost)
+ // This should cause headers to be ignored
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithKnownProxies("192.168.1.100");
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Headers_should_be_ignored_when_caller_not_in_known_proxies()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // Simulate request from IP 203.0.113.1 (not in known proxies)
+ // The known proxy is 192.168.1.100, so this IP should be rejected
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com",
+ testRemoteIp: "203.0.113.1");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersIgnoredWhenProxyNotTrusted(
+ requestInfo,
+ sentXForwardedFor: "203.0.113.50",
+ sentXForwardedProto: "https",
+ sentXForwardedHost: "example.com");
+
+ // Verify configuration shows the trusted proxy (203.0.113.1 is NOT this proxy)
+ Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("192.168.1.100"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/Https/When_hsts_is_configured.cs b/src/ServiceControl.AcceptanceTests/Security/Https/When_hsts_is_configured.cs
new file mode 100644
index 0000000000..b9f47b6774
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/Https/When_hsts_is_configured.cs
@@ -0,0 +1,54 @@
+namespace ServiceControl.AcceptanceTests.Security.Https
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Https;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// HSTS Configuration
+ /// Note: HSTS only applies in non-development environments.
+ /// In acceptance tests (which run in Development mode), HSTS headers are not sent.
+ /// This test verifies that HSTS is correctly NOT applied in development mode.
+ ///
+ class When_hsts_is_configured : AcceptanceTest
+ {
+ HttpsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureHttps() =>
+ configuration = new HttpsTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithHstsEnabled()
+ .WithHstsMaxAge(31536000)
+ .WithHstsIncludeSubDomains();
+
+ [TearDown]
+ public void CleanupHttps() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_not_include_hsts_header_in_development_mode()
+ {
+ // HSTS is intentionally NOT applied in development environments
+ // This is ASP.NET Core's default behavior to prevent HSTS from being cached
+ // by browsers during development, which could cause issues when switching
+ // between HTTP and HTTPS during testing.
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await this.GetRaw("/api");
+ return response != null;
+ })
+ .Run();
+
+ HttpsAssertions.AssertNoHstsHeader(response);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/Https/When_https_redirect_is_disabled.cs b/src/ServiceControl.AcceptanceTests/Security/Https/When_https_redirect_is_disabled.cs
new file mode 100644
index 0000000000..e430d9a32c
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/Https/When_https_redirect_is_disabled.cs
@@ -0,0 +1,46 @@
+namespace ServiceControl.AcceptanceTests.Security.Https
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Https;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// HTTPS Redirect Disabled (Default)
+ /// When RedirectHttpToHttps is false (default), HTTP requests should not be redirected.
+ ///
+ class When_https_redirect_is_disabled : AcceptanceTest
+ {
+ HttpsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureHttps() =>
+ configuration = new HttpsTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithRedirectHttpToHttpsDisabled();
+
+ [TearDown]
+ public void CleanupHttps() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_not_redirect_http_requests()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await this.GetRaw("/api");
+ return response != null;
+ })
+ .Run();
+
+ HttpsAssertions.AssertNoHttpsRedirect(response);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/Https/When_https_redirect_is_enabled.cs b/src/ServiceControl.AcceptanceTests/Security/Https/When_https_redirect_is_enabled.cs
new file mode 100644
index 0000000000..27f2df4b2f
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/Https/When_https_redirect_is_enabled.cs
@@ -0,0 +1,47 @@
+namespace ServiceControl.AcceptanceTests.Security.Https
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Https;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// HTTPS Redirect Enabled
+ /// When RedirectHttpToHttps is true, HTTP requests should be redirected to HTTPS.
+ ///
+ class When_https_redirect_is_enabled : AcceptanceTest
+ {
+ HttpsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureHttps() =>
+ configuration = new HttpsTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithRedirectHttpToHttps()
+ .WithHttpsPort(443);
+
+ [TearDown]
+ public void CleanupHttps() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_redirect_http_requests_to_https()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await this.GetRaw("/api");
+ return response != null;
+ })
+ .Run();
+
+ HttpsAssertions.AssertHttpsRedirect(response);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_disabled.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_disabled.cs
new file mode 100644
index 0000000000..21dc9bb5b1
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_disabled.cs
@@ -0,0 +1,70 @@
+namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.OpenIdConnect;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Authentication Disabled (Default)
+ /// When Authentication.Enabled is false (default), all API endpoints should be accessible
+ /// without authentication. Requests should not require Bearer tokens.
+ ///
+ class When_authentication_is_disabled : AcceptanceTest
+ {
+ OpenIdConnectTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureAuth() =>
+ configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithAuthenticationDisabled();
+
+ [TearDown]
+ public void CleanupAuth() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_allow_requests_without_authentication()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // Use /api/errors to test an endpoint that would require auth if enabled
+ response = await OpenIdConnectAssertions.SendRequestWithoutAuth(
+ HttpClient,
+ HttpMethod.Get,
+ "/api/errors");
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertNoAuthenticationRequired(response);
+ }
+
+ [Test]
+ public async Task Should_return_authentication_configuration_as_disabled()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await OpenIdConnectAssertions.SendRequestWithoutAuth(
+ HttpClient,
+ HttpMethod.Get,
+ "/api/authentication/configuration");
+ return response != null;
+ })
+ .Run();
+
+ await OpenIdConnectAssertions.AssertAuthConfigurationResponse(response, expectedEnabled: false);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs
new file mode 100644
index 0000000000..16395ccd3e
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs
@@ -0,0 +1,186 @@
+namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.OpenIdConnect;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Authentication Enabled
+ /// When Authentication.Enabled is true, the authentication configuration endpoint should
+ /// return the configured OIDC settings so that clients (like ServicePulse) can discover
+ /// how to authenticate.
+ ///
+ /// This test uses a mock OIDC server to provide discovery endpoints and signing keys,
+ /// allowing full testing of the JWT Bearer authentication flow.
+ ///
+ class When_authentication_is_enabled : AcceptanceTest
+ {
+ OpenIdConnectTestConfiguration configuration;
+ MockOidcServer mockOidcServer;
+
+ const string TestAudience = "api://test-audience";
+ const string TestClientId = "test-client-id";
+ const string TestApiScopes = "api://test-audience/.default";
+
+ [SetUp]
+ public void ConfigureAuth()
+ {
+ // Start mock OIDC server
+ mockOidcServer = new MockOidcServer(audience: TestAudience);
+ mockOidcServer.Start();
+
+ configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithConfigurationValidationDisabled()
+ .WithAuthenticationEnabled()
+ .WithAuthority(mockOidcServer.Authority)
+ .WithAudience(TestAudience)
+ .WithServicePulseClientId(TestClientId)
+ .WithServicePulseApiScopes(TestApiScopes)
+ .WithRequireHttpsMetadata(false);
+ }
+
+ [TearDown]
+ public void CleanupAuth()
+ {
+ configuration?.Dispose();
+ mockOidcServer?.Dispose();
+ }
+
+ [Test]
+ public async Task Should_return_authentication_configuration_with_enabled_true()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // The authentication/configuration endpoint is marked [AllowAnonymous]
+ // so it should be accessible without authentication
+ response = await OpenIdConnectAssertions.SendRequestWithoutAuth(
+ HttpClient,
+ HttpMethod.Get,
+ "/api/authentication/configuration");
+ return response != null;
+ })
+ .Run();
+
+ await OpenIdConnectAssertions.AssertAuthConfigurationResponse(
+ response,
+ expectedEnabled: true,
+ expectedClientId: TestClientId,
+ expectedAudience: TestAudience,
+ expectedApiScopes: TestApiScopes);
+ }
+
+ [Test]
+ public async Task Should_reject_requests_without_bearer_token()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // Use /api/errors which does NOT have [AllowAnonymous] so it should require authentication
+ // Note: /api is marked [AllowAnonymous] for server-to-server configuration fetching
+ response = await OpenIdConnectAssertions.SendRequestWithoutAuth(
+ HttpClient,
+ HttpMethod.Get,
+ "/api/errors");
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertUnauthorized(response);
+ }
+
+ [Test]
+ public async Task Should_reject_requests_with_invalid_bearer_token()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
+ HttpClient,
+ HttpMethod.Get,
+ "/api/errors",
+ "invalid-token-value");
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertUnauthorized(response);
+ }
+
+ [Test]
+ public async Task Should_accept_requests_with_valid_bearer_token()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ var validToken = mockOidcServer.GenerateToken();
+ response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
+ HttpClient,
+ HttpMethod.Get,
+ "/api/errors",
+ validToken);
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertAuthenticated(response);
+ }
+
+ [Test]
+ public async Task Should_reject_requests_with_expired_token()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ var expiredToken = mockOidcServer.GenerateExpiredToken();
+ response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
+ HttpClient,
+ HttpMethod.Get,
+ "/api/errors",
+ expiredToken);
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertUnauthorized(response);
+ }
+
+ [Test]
+ public async Task Should_reject_requests_with_wrong_audience()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ var wrongAudienceToken = mockOidcServer.GenerateTokenWithWrongAudience();
+ response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
+ HttpClient,
+ HttpMethod.Get,
+ "/api/errors",
+ wrongAudienceToken);
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertUnauthorized(response);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_service_pulse_authority_override_is_configured.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_service_pulse_authority_override_is_configured.cs
new file mode 100644
index 0000000000..b171ec097b
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_service_pulse_authority_override_is_configured.cs
@@ -0,0 +1,70 @@
+namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.OpenIdConnect;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// ServicePulse Authority Override
+ /// When Authentication.ServicePulse.Authority is configured, the authentication configuration
+ /// endpoint should return the overridden authority for ServicePulse to use instead of the
+ /// main authority URL.
+ ///
+ class When_service_pulse_authority_override_is_configured : AcceptanceTest
+ {
+ OpenIdConnectTestConfiguration configuration;
+
+ const string MainAuthority = "https://login.main.example.com/tenant-id/v2.0";
+ const string ServicePulseAuthority = "https://login.pulse.example.com/tenant-id/v2.0";
+ const string TestAudience = "api://test-audience";
+ const string TestClientId = "test-client-id";
+ const string TestApiScopes = "api://test-audience/.default";
+
+ [SetUp]
+ public void ConfigureAuth() =>
+ configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithConfigurationValidationDisabled()
+ .WithAuthenticationEnabled()
+ .WithAuthority(MainAuthority)
+ .WithAudience(TestAudience)
+ .WithServicePulseClientId(TestClientId)
+ .WithServicePulseApiScopes(TestApiScopes)
+ .WithServicePulseAuthority(ServicePulseAuthority)
+ .WithRequireHttpsMetadata(false);
+
+ [TearDown]
+ public void CleanupAuth() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_return_service_pulse_authority_in_configuration()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await OpenIdConnectAssertions.SendRequestWithoutAuth(
+ HttpClient,
+ HttpMethod.Get,
+ "/api/authentication/configuration");
+ return response != null;
+ })
+ .Run();
+
+ await OpenIdConnectAssertions.AssertAuthConfigurationResponse(
+ response,
+ expectedEnabled: true,
+ expectedClientId: TestClientId,
+ expectedAuthority: ServicePulseAuthority,
+ expectedAudience: TestAudience,
+ expectedApiScopes: TestApiScopes);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
index 8ac0a4454a..e9df6cf5b2 100644
--- a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
+++ b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
@@ -2,13 +2,16 @@
{
using System;
using System.IO;
+ using System.Net;
using System.Net.Http;
using System.Runtime.Loader;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using AcceptanceTesting;
+ using Hosting.Auth;
using Hosting.Commands;
+ using Hosting.Https;
using Infrastructure.DomainEvents;
using Infrastructure.WebApi;
using Microsoft.AspNetCore.Builder;
@@ -119,15 +122,35 @@ async Task InitializeServiceControl(ScenarioContext context)
// Force the DI container to run the dependency resolution check to verify all dependencies can be resolved
EnvironmentName = Environments.Development
});
+ hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings);
hostBuilder.AddServiceControl(settings, configuration);
- hostBuilder.AddServiceControlApi();
+ hostBuilder.AddServiceControlHttps(settings.HttpsSettings);
+ hostBuilder.AddServiceControlApi(settings.CorsSettings);
hostBuilder.AddServiceControlTesting(settings);
hostBuilderCustomization(hostBuilder);
host = hostBuilder.Build();
- host.UseServiceControl();
+
+ // Test middleware: Set RemoteIpAddress from X-Test-Remote-IP header
+ // This must run BEFORE UseServiceControl (which adds ForwardedHeaders middleware)
+ // so that the ForwardedHeaders middleware can properly check KnownProxies/KnownNetworks
+ _ = host.Use(async (context, next) =>
+ {
+ if (context.Request.Headers.TryGetValue("X-Test-Remote-IP", out var testIpHeader))
+ {
+ var testIpValue = testIpHeader.ToString();
+ if (IPAddress.TryParse(testIpValue, out var testIp))
+ {
+ context.Connection.RemoteIpAddress = testIp;
+ }
+ }
+ await next();
+ });
+
+ host.UseServiceControlAuthentication(settings.OpenIdConnectSettings.Enabled);
+ host.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings);
await host.StartAsync();
DomainEvents = host.Services.GetRequiredService();
// Bring this back and look into the base address of the client
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/Cors/When_cors_allows_any_origin.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/Cors/When_cors_allows_any_origin.cs
new file mode 100644
index 0000000000..2b61eea540
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/Cors/When_cors_allows_any_origin.cs
@@ -0,0 +1,92 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.Cors
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Cors;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Default CORS Behavior (AllowAnyOrigin = true)
+ /// When AllowAnyOrigin is true (default for backwards compatibility), requests from any origin should be allowed.
+ /// The Access-Control-Allow-Origin header should be "*".
+ ///
+ class When_cors_allows_any_origin : AcceptanceTest
+ {
+ CorsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureCors() =>
+ // Default behavior - allow any origin
+ configuration = new CorsTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithAllowAnyOrigin();
+
+ [TearDown]
+ public void CleanupCors() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_return_wildcard_access_control_allow_origin_header()
+ {
+ HttpResponseMessage response = null;
+ const string testOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: testOrigin,
+ endpoint: "/api");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertAllowAnyOrigin(response, testOrigin);
+ }
+
+ [Test]
+ public async Task Should_return_expected_allowed_methods()
+ {
+ HttpResponseMessage response = null;
+ const string testOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendPreflightRequest(
+ HttpClient,
+ origin: testOrigin,
+ requestMethod: "POST");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertAllowedMethods(response, "POST", "GET", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD");
+ }
+
+ [Test]
+ public async Task Should_return_expected_exposed_headers()
+ {
+ HttpResponseMessage response = null;
+ const string testOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: testOrigin,
+ endpoint: "/api");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertExposedHeaders(response, "ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/Cors/When_cors_is_disabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/Cors/When_cors_is_disabled.cs
new file mode 100644
index 0000000000..85c96fc146
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/Cors/When_cors_is_disabled.cs
@@ -0,0 +1,72 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.Cors
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Cors;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// CORS Disabled
+ /// When AllowAnyOrigin is false and no AllowedOrigins are configured, CORS is effectively disabled.
+ /// No Access-Control-Allow-Origin header should be returned for any origin.
+ ///
+ class When_cors_is_disabled : AcceptanceTest
+ {
+ CorsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureCors() =>
+ // Disable CORS by setting AllowAnyOrigin to false and not configuring any origins
+ configuration = new CorsTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithCorsDisabled();
+
+ [TearDown]
+ public void CleanupCors() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_not_return_access_control_allow_origin_header()
+ {
+ HttpResponseMessage response = null;
+ const string testOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: testOrigin,
+ endpoint: "/api");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertCorsDisabled(response);
+ }
+
+ [Test]
+ public async Task Preflight_request_should_not_return_cors_headers()
+ {
+ HttpResponseMessage response = null;
+ const string testOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendPreflightRequest(
+ HttpClient,
+ origin: testOrigin,
+ requestMethod: "POST");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertCorsDisabled(response);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/Cors/When_request_from_allowed_origin.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/Cors/When_request_from_allowed_origin.cs
new file mode 100644
index 0000000000..5a2d605c3a
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/Cors/When_request_from_allowed_origin.cs
@@ -0,0 +1,93 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.Cors
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Cors;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Request from Allowed Origin
+ /// When a request comes from an origin that is in the AllowedOrigins list,
+ /// the response should include the Access-Control-Allow-Origin header with that specific origin.
+ ///
+ class When_request_from_allowed_origin : AcceptanceTest
+ {
+ CorsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureCors() =>
+ // Configure specific allowed origins
+ configuration = new CorsTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithAllowedOrigins("https://app.example.com,https://admin.example.com");
+
+ [TearDown]
+ public void CleanupCors() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_return_matching_origin_in_access_control_allow_origin_header()
+ {
+ HttpResponseMessage response = null;
+ const string allowedOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: allowedOrigin,
+ endpoint: "/api");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertAllowedOrigin(response, allowedOrigin);
+ }
+
+ [Test]
+ public async Task Should_allow_second_configured_origin()
+ {
+ HttpResponseMessage response = null;
+ const string allowedOrigin = "https://admin.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: allowedOrigin,
+ endpoint: "/api");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertAllowedOrigin(response, allowedOrigin);
+ }
+
+ [Test]
+ public async Task Preflight_request_should_return_correct_cors_headers()
+ {
+ HttpResponseMessage response = null;
+ const string allowedOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendPreflightRequest(
+ HttpClient,
+ origin: allowedOrigin,
+ requestMethod: "POST");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertAllowedOrigin(response, allowedOrigin);
+ CorsAssertions.AssertAllowedMethods(response, "POST", "GET", "PUT", "DELETE");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/Cors/When_request_from_disallowed_origin.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/Cors/When_request_from_disallowed_origin.cs
new file mode 100644
index 0000000000..2374598124
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/Cors/When_request_from_disallowed_origin.cs
@@ -0,0 +1,135 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.Cors
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Cors;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Request from Disallowed Origin
+ /// When a request comes from an origin that is NOT in the AllowedOrigins list,
+ /// the response should NOT include an Access-Control-Allow-Origin header that matches the request origin.
+ ///
+ class When_request_from_disallowed_origin : AcceptanceTest
+ {
+ CorsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureCors() =>
+ // Configure specific allowed origins - the test origin won't be in this list
+ configuration = new CorsTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithAllowedOrigins("https://app.example.com,https://admin.example.com");
+
+ [TearDown]
+ public void CleanupCors() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_not_return_access_control_allow_origin_header_for_disallowed_origin()
+ {
+ HttpResponseMessage response = null;
+ const string disallowedOrigin = "https://malicious.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: disallowedOrigin,
+ endpoint: "/api");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertOriginNotAllowed(response, disallowedOrigin);
+ }
+
+ [Test]
+ public async Task Should_not_allow_origin_with_different_scheme()
+ {
+ HttpResponseMessage response = null;
+ // http:// instead of https://
+ const string disallowedOrigin = "http://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: disallowedOrigin,
+ endpoint: "/api");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertOriginNotAllowed(response, disallowedOrigin);
+ }
+
+ [Test]
+ public async Task Should_not_allow_origin_with_different_port()
+ {
+ HttpResponseMessage response = null;
+ // Different port
+ const string disallowedOrigin = "https://app.example.com:8080";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: disallowedOrigin,
+ endpoint: "/api");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertOriginNotAllowed(response, disallowedOrigin);
+ }
+
+ [Test]
+ public async Task Should_not_allow_subdomain_when_parent_domain_is_configured()
+ {
+ HttpResponseMessage response = null;
+ // Subdomain of allowed origin
+ const string disallowedOrigin = "https://sub.app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: disallowedOrigin,
+ endpoint: "/api");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertOriginNotAllowed(response, disallowedOrigin);
+ }
+
+ [Test]
+ public async Task Preflight_request_should_not_allow_disallowed_origin()
+ {
+ HttpResponseMessage response = null;
+ const string disallowedOrigin = "https://malicious.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendPreflightRequest(
+ HttpClient,
+ origin: disallowedOrigin,
+ requestMethod: "POST");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertOriginNotAllowed(response, disallowedOrigin);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs
new file mode 100644
index 0000000000..659b613a7a
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs
@@ -0,0 +1,63 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Combined Known Proxies and Networks
+ /// When both KnownProxies and KnownNetworks are configured, matching either grants trust.
+ ///
+ class When_combined_proxies_and_networks_are_configured : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Configure both proxies (that don't match localhost) and networks (that include localhost)
+ // The localhost should match via the networks, proving OR logic
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithKnownProxiesAndNetworks("192.168.1.100", "127.0.0.0/8,::1/128");
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Headers_should_be_applied_when_caller_matches_network_but_not_proxy()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+
+ using (Assert.EnterMultipleScope())
+ {
+ // Verify configuration shows both proxies and networks
+ Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("192.168.1.100"));
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8").Or.Contain("::1/128"));
+ }
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs
new file mode 100644
index 0000000000..99c4a9fa9f
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs
@@ -0,0 +1,55 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Forwarded Headers Disabled
+ /// When forwarded headers processing is disabled, headers should be ignored regardless of trust.
+ ///
+ class When_forwarded_headers_are_disabled : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Disable forwarded headers processing entirely
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithForwardedHeadersDisabled();
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Headers_should_be_ignored_when_disabled()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersIgnoredWhenDisabled(
+ requestInfo,
+ sentXForwardedFor: "203.0.113.50",
+ sentXForwardedProto: "https",
+ sentXForwardedHost: "example.com");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs
new file mode 100644
index 0000000000..5de9cfc68e
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs
@@ -0,0 +1,44 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Default Behavior with Headers
+ /// When forwarded headers are sent and TrustAllProxies is true (default), headers should be applied.
+ ///
+ class When_forwarded_headers_are_sent : AcceptanceTest
+ {
+ [Test]
+ public async Task Headers_should_be_applied_when_trust_all_proxies()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWhenTrustAllProxies(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs
new file mode 100644
index 0000000000..646a8af5b6
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs
@@ -0,0 +1,58 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Known Networks (CIDR)
+ /// When KnownNetworks are configured and the caller IP falls within, headers should be applied.
+ ///
+ class When_known_networks_are_configured : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Configure known networks to include localhost CIDR ranges (test server uses localhost)
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithKnownNetworks("127.0.0.0/8,::1/128");
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Headers_should_be_applied_when_caller_matches_known_network()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+
+ // Verify configuration shows known networks
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8").Or.Contain("::1/128"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs
new file mode 100644
index 0000000000..d622f22e7f
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs
@@ -0,0 +1,58 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Known Proxies Only
+ /// When KnownProxies are configured and the caller IP matches, headers should be applied.
+ ///
+ class When_known_proxies_are_configured : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Configure known proxies to include localhost addresses (test server uses localhost)
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithKnownProxies("127.0.0.1,::1");
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Headers_should_be_applied_when_caller_matches_known_proxy()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+
+ // Verify configuration shows known proxies
+ Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("127.0.0.1").Or.Contain("::1"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs
new file mode 100644
index 0000000000..d2f54f4d2d
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs
@@ -0,0 +1,38 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Partial Headers (Proto Only)
+ /// When only X-Forwarded-Proto is sent, only scheme should change.
+ ///
+ class When_only_proto_header_is_sent : AcceptanceTest
+ {
+ [Test]
+ public async Task Only_scheme_should_be_changed()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedProto: "https");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertPartialHeadersApplied(requestInfo, expectedScheme: "https");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs
new file mode 100644
index 0000000000..607dcb4688
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs
@@ -0,0 +1,48 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Proxy Chain (Multiple X-Forwarded-For Values)
+ /// When TrustAllProxies is true and X-Forwarded-For contains multiple IPs (proxy chain),
+ /// the original client IP (first in the chain) should be returned.
+ ///
+ class When_proxy_chain_headers_are_sent : AcceptanceTest
+ {
+ [Test]
+ public async Task Original_client_ip_should_be_returned_when_trust_all_proxies()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // Simulate a proxy chain: client -> proxy1 -> proxy2 -> ServiceControl.Audit
+ // X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1
+ // Expected: 203.0.113.50 (original client)
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertProxyChainProcessedWithTrustAllProxies(
+ requestInfo,
+ expectedOriginalClientIp: "203.0.113.50",
+ expectedScheme: "https",
+ expectedHost: "example.com");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs
new file mode 100644
index 0000000000..aed99352e9
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs
@@ -0,0 +1,59 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Proxy Chain with Known Proxies (ForwardLimit=1)
+ /// When TrustAllProxies=false (known proxies configured), ForwardLimit=1, so only the last proxy IP is processed.
+ ///
+ class When_proxy_chain_headers_are_sent_with_known_proxies : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Configure known proxies to include localhost (test server uses localhost)
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithKnownProxies("127.0.0.1,::1");
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Only_last_proxy_ip_should_be_processed_when_forward_limit_is_one()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // Simulate a proxy chain: client -> proxy1 -> proxy2 -> ServiceControl.Audit
+ // X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1
+ // Expected with ForwardLimit=1: 192.168.1.1 (last proxy in chain)
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertProxyChainWithForwardLimitOne(
+ requestInfo,
+ expectedLastProxyIp: "192.168.1.1",
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemainingForwardedFor: "203.0.113.50,10.0.0.1");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs
new file mode 100644
index 0000000000..9dcca4daca
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs
@@ -0,0 +1,40 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Direct Access (No Proxy)
+ /// When no forwarded headers are sent, the request values should remain unchanged.
+ ///
+ class When_request_has_no_forwarded_headers : AcceptanceTest
+ {
+ [Test]
+ public async Task Request_values_should_remain_unchanged()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ var result = await this.TryGet("/debug/request-info");
+ if (result.HasResult)
+ {
+ requestInfo = result.Item;
+ return true;
+ }
+ return false;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertDirectAccessWithNoForwardedHeaders(requestInfo);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs
new file mode 100644
index 0000000000..419be4db2e
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs
@@ -0,0 +1,63 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Unknown Network Rejected
+ /// When KnownNetworks are configured but the caller IP does NOT fall within, headers should be ignored.
+ ///
+ class When_unknown_network_sends_headers : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Configure known networks that do NOT include localhost (test server uses localhost)
+ // This should cause headers to be ignored
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithKnownNetworks("10.0.0.0/8,192.168.0.0/16");
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Headers_should_be_ignored_when_caller_not_in_known_networks()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // Simulate request from IP 203.0.113.1 (TEST-NET-3, not in known networks)
+ // The known networks are 10.0.0.0/8 and 192.168.0.0/16, so this IP should be rejected
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com",
+ testRemoteIp: "203.0.113.1");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersIgnoredWhenProxyNotTrusted(
+ requestInfo,
+ sentXForwardedFor: "203.0.113.50",
+ sentXForwardedProto: "https",
+ sentXForwardedHost: "example.com");
+
+ // Verify configuration shows the networks (203.0.113.1 is NOT in these networks)
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("10.0.0.0/8"));
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("192.168.0.0/16"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs
new file mode 100644
index 0000000000..1a18c759dc
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs
@@ -0,0 +1,62 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Unknown Proxy Rejected
+ /// When KnownProxies are configured but the caller IP does NOT match, headers should be ignored.
+ ///
+ class When_unknown_proxy_sends_headers : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Configure a known proxy that does NOT match localhost (test server uses localhost)
+ // This should cause headers to be ignored
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithKnownProxies("192.168.1.100");
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Headers_should_be_ignored_when_caller_not_in_known_proxies()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // Simulate request from IP 203.0.113.1 (TEST-NET-3, not in known proxies)
+ // The known proxy is 192.168.1.100, so this IP should be rejected
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com",
+ testRemoteIp: "203.0.113.1");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersIgnoredWhenProxyNotTrusted(
+ requestInfo,
+ sentXForwardedFor: "203.0.113.50",
+ sentXForwardedProto: "https",
+ sentXForwardedHost: "example.com");
+
+ // Verify configuration shows the trusted proxy (203.0.113.1 is NOT this proxy)
+ Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("192.168.1.100"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/Https/When_hsts_is_configured.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/Https/When_hsts_is_configured.cs
new file mode 100644
index 0000000000..885f886a8e
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/Https/When_hsts_is_configured.cs
@@ -0,0 +1,54 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.Https
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Https;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// HSTS Configuration
+ /// Note: HSTS only applies in non-development environments.
+ /// In acceptance tests (which run in Development mode), HSTS headers are not sent.
+ /// This test verifies that HSTS is correctly NOT applied in development mode.
+ ///
+ class When_hsts_is_configured : AcceptanceTest
+ {
+ HttpsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureHttps() =>
+ configuration = new HttpsTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithHstsEnabled()
+ .WithHstsMaxAge(31536000)
+ .WithHstsIncludeSubDomains();
+
+ [TearDown]
+ public void CleanupHttps() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_not_include_hsts_header_in_development_mode()
+ {
+ // HSTS is intentionally NOT applied in development environments
+ // This is ASP.NET Core's default behavior to prevent HSTS from being cached
+ // by browsers during development, which could cause issues when switching
+ // between HTTP and HTTPS during testing.
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await this.GetRaw("/api");
+ return response != null;
+ })
+ .Run();
+
+ HttpsAssertions.AssertNoHstsHeader(response);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/Https/When_https_redirect_is_disabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/Https/When_https_redirect_is_disabled.cs
new file mode 100644
index 0000000000..5dc6a1c610
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/Https/When_https_redirect_is_disabled.cs
@@ -0,0 +1,46 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.Https
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Https;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// HTTPS Redirect Disabled (Default)
+ /// When RedirectHttpToHttps is false (default), HTTP requests should not be redirected.
+ ///
+ class When_https_redirect_is_disabled : AcceptanceTest
+ {
+ HttpsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureHttps() =>
+ configuration = new HttpsTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithRedirectHttpToHttpsDisabled();
+
+ [TearDown]
+ public void CleanupHttps() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_not_redirect_http_requests()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await this.GetRaw("/api");
+ return response != null;
+ })
+ .Run();
+
+ HttpsAssertions.AssertNoHttpsRedirect(response);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/Https/When_https_redirect_is_enabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/Https/When_https_redirect_is_enabled.cs
new file mode 100644
index 0000000000..b4769af7eb
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/Https/When_https_redirect_is_enabled.cs
@@ -0,0 +1,47 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.Https
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Https;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// HTTPS Redirect Enabled
+ /// When RedirectHttpToHttps is true, HTTP requests should be redirected to HTTPS.
+ ///
+ class When_https_redirect_is_enabled : AcceptanceTest
+ {
+ HttpsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureHttps() =>
+ configuration = new HttpsTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithRedirectHttpToHttps()
+ .WithHttpsPort(443);
+
+ [TearDown]
+ public void CleanupHttps() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_redirect_http_requests_to_https()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await this.GetRaw("/api");
+ return response != null;
+ })
+ .Run();
+
+ HttpsAssertions.AssertHttpsRedirect(response);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_disabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_disabled.cs
new file mode 100644
index 0000000000..2cbcc12fbc
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_disabled.cs
@@ -0,0 +1,51 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.OpenIdConnect
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.OpenIdConnect;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Authentication Disabled (Default)
+ /// When Authentication.Enabled is false (default), all API endpoints should be accessible
+ /// without authentication. Requests should not require Bearer tokens.
+ ///
+ class When_authentication_is_disabled : AcceptanceTest
+ {
+ OpenIdConnectTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureAuth() =>
+ configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithAuthenticationDisabled();
+
+ [TearDown]
+ public void CleanupAuth() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_allow_requests_without_authentication()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // Use /api/messages to test an endpoint that would require auth if enabled
+ response = await OpenIdConnectAssertions.SendRequestWithoutAuth(
+ HttpClient,
+ HttpMethod.Get,
+ "/api/messages");
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertNoAuthenticationRequired(response);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs
new file mode 100644
index 0000000000..4d0bd55196
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs
@@ -0,0 +1,174 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.OpenIdConnect
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.OpenIdConnect;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Authentication Enabled
+ /// When Authentication.Enabled is true, API endpoints should require a valid JWT Bearer token
+ /// unless marked with [AllowAnonymous].
+ ///
+ /// This test uses a mock OIDC server to provide discovery endpoints and signing keys,
+ /// allowing full testing of the JWT Bearer authentication flow.
+ ///
+ class When_authentication_is_enabled : AcceptanceTest
+ {
+ OpenIdConnectTestConfiguration configuration;
+ MockOidcServer mockOidcServer;
+
+ const string TestAudience = "api://test-audience";
+
+ [SetUp]
+ public void ConfigureAuth()
+ {
+ // Start mock OIDC server
+ mockOidcServer = new MockOidcServer(audience: TestAudience);
+ mockOidcServer.Start();
+
+ configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithConfigurationValidationDisabled()
+ .WithAuthenticationEnabled()
+ .WithAuthority(mockOidcServer.Authority)
+ .WithAudience(TestAudience)
+ .WithRequireHttpsMetadata(false);
+ }
+
+ [TearDown]
+ public void CleanupAuth()
+ {
+ configuration?.Dispose();
+ mockOidcServer?.Dispose();
+ }
+
+ [Test]
+ public async Task Should_reject_requests_without_bearer_token()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // Use /api/messages which does NOT have [AllowAnonymous] so it should require authentication
+ response = await OpenIdConnectAssertions.SendRequestWithoutAuth(
+ HttpClient,
+ HttpMethod.Get,
+ "/api/messages");
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertUnauthorized(response);
+ }
+
+ [Test]
+ public async Task Should_reject_requests_with_invalid_bearer_token()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
+ HttpClient,
+ HttpMethod.Get,
+ "/api/messages",
+ "invalid-token-value");
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertUnauthorized(response);
+ }
+
+ [Test]
+ public async Task Should_accept_requests_with_valid_bearer_token()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ var validToken = mockOidcServer.GenerateToken();
+ response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
+ HttpClient,
+ HttpMethod.Get,
+ "/api/messages",
+ validToken);
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertAuthenticated(response);
+ }
+
+ [Test]
+ public async Task Should_reject_requests_with_expired_token()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ var expiredToken = mockOidcServer.GenerateExpiredToken();
+ response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
+ HttpClient,
+ HttpMethod.Get,
+ "/api/messages",
+ expiredToken);
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertUnauthorized(response);
+ }
+
+ [Test]
+ public async Task Should_reject_requests_with_wrong_audience()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ var wrongAudienceToken = mockOidcServer.GenerateTokenWithWrongAudience();
+ response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
+ HttpClient,
+ HttpMethod.Get,
+ "/api/messages",
+ wrongAudienceToken);
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertUnauthorized(response);
+ }
+
+ [Test]
+ public async Task Should_allow_anonymous_access_to_root_endpoint()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // The /api endpoint is marked [AllowAnonymous] for discovery
+ response = await OpenIdConnectAssertions.SendRequestWithoutAuth(
+ HttpClient,
+ HttpMethod.Get,
+ "/api");
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertNoAuthenticationRequired(response);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
index e0b41effe6..505080e004 100644
--- a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
+++ b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
@@ -4,6 +4,7 @@ namespace ServiceControl.Audit.AcceptanceTests.TestSupport
using System.Collections.Generic;
using System.Configuration;
using System.IO;
+ using System.Net;
using System.Net.Http;
using System.Runtime.Loader;
using System.Text.Json;
@@ -19,6 +20,8 @@ namespace ServiceControl.Audit.AcceptanceTests.TestSupport
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NServiceBus;
+ using ServiceControl.Hosting.Auth;
+ using ServiceControl.Hosting.Https;
using NServiceBus.AcceptanceTesting;
using NServiceBus.AcceptanceTesting.Support;
using ServiceControl.Infrastructure;
@@ -116,6 +119,7 @@ async Task InitializeServiceControl(ScenarioContext context)
// Force the DI container to run the dependency resolution check to verify all dependencies can be resolved
EnvironmentName = Environments.Development
});
+ hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings);
hostBuilder.AddServiceControlAudit((criticalErrorContext, cancellationToken) =>
{
var logitem = new ScenarioContext.LogItem
@@ -129,14 +133,33 @@ async Task InitializeServiceControl(ScenarioContext context)
return criticalErrorContext.Stop(cancellationToken);
}, settings, configuration);
- hostBuilder.AddServiceControlAuditApi();
+ hostBuilder.AddServiceControlAuditApi(settings.CorsSettings);
+ hostBuilder.AddServiceControlHttps(settings.HttpsSettings);
hostBuilder.AddServiceControlAuditTesting(settings);
hostBuilderCustomization(hostBuilder);
host = hostBuilder.Build();
- host.UseServiceControlAudit();
+
+ // Test middleware: Set RemoteIpAddress from X-Test-Remote-IP header
+ // This must run BEFORE UseServiceControlAudit (which adds ForwardedHeaders middleware)
+ // so that the ForwardedHeaders middleware can properly check KnownProxies/KnownNetworks
+ _ = host.Use(async (context, next) =>
+ {
+ if (context.Request.Headers.TryGetValue("X-Test-Remote-IP", out var testIpHeader))
+ {
+ var testIpValue = testIpHeader.ToString();
+ if (IPAddress.TryParse(testIpValue, out var testIp))
+ {
+ context.Connection.RemoteIpAddress = testIp;
+ }
+ }
+ await next();
+ });
+
+ host.UseServiceControlAuthentication(settings.OpenIdConnectSettings.Enabled);
+ host.UseServiceControlAudit(settings.ForwardedHeadersSettings, settings.HttpsSettings);
await host.StartAsync();
ServiceProvider = host.Services;
InstanceTestServer = host.GetTestServer();
diff --git a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
index 7113c06339..63976a1429 100644
--- a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
+++ b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
@@ -3,6 +3,39 @@
"LogLevel": "Information",
"LogPath": "C:\\Logs"
},
+ "OpenIdConnectSettings": {
+ "enabled": false,
+ "authority": null,
+ "audience": null,
+ "validateIssuer": true,
+ "validateAudience": true,
+ "validateLifetime": true,
+ "validateIssuerSigningKey": true,
+ "requireHttpsMetadata": true,
+ "servicePulseAuthority": null,
+ "servicePulseClientId": null,
+ "servicePulseApiScopes": null
+ },
+ "ForwardedHeadersSettings": {
+ "Enabled": true,
+ "TrustAllProxies": true,
+ "KnownProxiesRaw": [],
+ "KnownNetworks": []
+ },
+ "HttpsSettings": {
+ "Enabled": false,
+ "CertificatePath": null,
+ "CertificatePassword": null,
+ "RedirectHttpToHttps": false,
+ "HttpsPort": null,
+ "EnableHsts": false,
+ "HstsMaxAgeSeconds": 31536000,
+ "HstsIncludeSubDomains": false
+ },
+ "CorsSettings": {
+ "AllowAnyOrigin": true,
+ "AllowedOrigins": []
+ },
"MessageFilter": null,
"ValidateConfiguration": true,
"RootUrl": "http://localhost:8888/",
diff --git a/src/ServiceControl.Audit/App.config b/src/ServiceControl.Audit/App.config
index 9452cd7e00..a3f5781c51 100644
--- a/src/ServiceControl.Audit/App.config
+++ b/src/ServiceControl.Audit/App.config
@@ -25,6 +25,49 @@ These settings are only here so that we can debug ServiceControl while developin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs
index e769a4863b..22e2fff776 100644
--- a/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs
+++ b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs
@@ -3,6 +3,8 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using NServiceBus;
+ using ServiceControl.Hosting.Auth;
+ using ServiceControl.Hosting.Https;
using Settings;
using WebApi;
@@ -15,15 +17,20 @@ public override async Task Execute(HostArguments args, Settings settings)
assemblyScanner.ExcludeAssemblies("ServiceControl.Plugin");
var hostBuilder = WebApplication.CreateBuilder();
+
+ hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings);
+ hostBuilder.AddServiceControlHttps(settings.HttpsSettings);
hostBuilder.AddServiceControlAudit((_, __) =>
{
//Do nothing. The transports in NSB 8 are designed to handle broker outages. Audit ingestion will be paused when broker is unavailable.
return Task.CompletedTask;
}, settings, endpointConfiguration);
- hostBuilder.AddServiceControlAuditApi();
+ hostBuilder.AddServiceControlAuditApi(settings.CorsSettings);
var app = hostBuilder.Build();
- app.UseServiceControlAudit();
+ app.UseServiceControlAudit(settings.ForwardedHeadersSettings, settings.HttpsSettings);
+ app.UseServiceControlAuthentication(settings.OpenIdConnectSettings.Enabled);
+
await app.RunAsync(settings.RootUrl);
}
}
diff --git a/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs b/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs
index dd409f0334..c74bfa2726 100644
--- a/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs
+++ b/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs
@@ -17,6 +17,11 @@ public Settings(string transportType = null, string persisterType = null, Loggin
{
LoggingSettings = loggingSettings ?? new(SettingsRootNamespace);
+ OpenIdConnectSettings = new OpenIdConnectSettings(SettingsRootNamespace, ValidateConfiguration, requireServicePulseSettings: false);
+ ForwardedHeadersSettings = new ForwardedHeadersSettings(SettingsRootNamespace);
+ HttpsSettings = new HttpsSettings(SettingsRootNamespace);
+ CorsSettings = new CorsSettings(SettingsRootNamespace);
+
// Overwrite the instance name if it is specified in ENVVAR, reg, or config file -- LEGACY SETTING NAME
InstanceName = SettingsReader.Read(SettingsRootNamespace, "InternalQueueName", InstanceName);
@@ -92,6 +97,26 @@ void LoadAuditQueueInformation()
public LoggingSettings LoggingSettings { get; }
+ ///
+ /// Security settings for API authentication and authorization
+ ///
+ public OpenIdConnectSettings OpenIdConnectSettings { get; }
+
+ ///
+ /// Settings for handling X-Forwarded-* headers from reverse proxies
+ ///
+ public ForwardedHeadersSettings ForwardedHeadersSettings { get; }
+
+ ///
+ /// Settings for enabling HTTPS/TLS on the API
+ ///
+ public HttpsSettings HttpsSettings { get; }
+
+ ///
+ /// Settings for Cross-Origin Resource Sharing (CORS) policy
+ ///
+ public CorsSettings CorsSettings { get; }
+
//HINT: acceptance tests only
public Func MessageFilter { get; set; }
@@ -108,7 +133,9 @@ public string RootUrl
suffix = $"{VirtualDirectory}/";
}
- return $"http://{Hostname}:{Port}/{suffix}";
+ // Use HTTPS scheme if TLS is enabled, otherwise HTTP
+ var scheme = HttpsSettings.Enabled ? "https" : "http";
+ return $"{scheme}://{Hostname}:{Port}/{suffix}";
}
}
diff --git a/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs b/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs
index c5b024930d..88fb3a43d7 100644
--- a/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs
+++ b/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs
@@ -1,17 +1,41 @@
namespace ServiceControl.Audit.Infrastructure.WebApi
{
using Microsoft.AspNetCore.Cors.Infrastructure;
+ using ServiceControl.Infrastructure;
+ ///
+ /// Configures Cross-Origin Resource Sharing (CORS) policy for the ServiceControl.Audit API.
+ /// CORS allows the API to be called from web applications hosted on different origins (e.g., ServicePulse).
+ ///
static class Cors
{
- public static CorsPolicy GetDefaultPolicy()
+ ///
+ /// Builds the default CORS policy based on the provided settings.
+ ///
+ public static CorsPolicy GetDefaultPolicy(CorsSettings settings)
{
var builder = new CorsPolicyBuilder();
- builder.AllowAnyOrigin();
+ // Configure allowed origins based on settings (defaults to true).
+ if (settings.AllowAnyOrigin)
+ {
+ // Allow requests from any origin (less secure, useful for development)
+ builder.AllowAnyOrigin();
+ }
+ else if (settings.AllowedOrigins.Count > 0)
+ {
+ // Allow only specific origins (more secure, recommended for production)
+ builder.WithOrigins([.. settings.AllowedOrigins]);
+ // Allow credentials (cookies, authorization headers) when specific origins are configured
+ builder.AllowCredentials();
+ }
+
+ // Headers exposed to the client in the response (accessible via JavaScript)
builder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version"]);
- builder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept"]);
- builder.WithMethods(["POST", "GET", "PUT", "DELETE", "OPTIONS", "PATCH"]);
+ // Headers allowed in the request from the client
+ builder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization"]);
+ // HTTP methods allowed for cross-origin requests
+ builder.WithMethods(["POST", "GET", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"]);
return builder.Build();
}
diff --git a/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs
index 4eaa203c64..638041d4b1 100644
--- a/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs
+++ b/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs
@@ -4,12 +4,13 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+ using ServiceControl.Infrastructure;
static class HostApplicationBuilderExtensions
{
- public static void AddServiceControlAuditApi(this IHostApplicationBuilder builder)
+ public static void AddServiceControlAuditApi(this IHostApplicationBuilder builder, CorsSettings corsSettings)
{
- builder.Services.AddCors(options => options.AddDefaultPolicy(Cors.GetDefaultPolicy()));
+ builder.Services.AddCors(options => options.AddDefaultPolicy(Cors.GetDefaultPolicy(corsSettings)));
// We're not explicitly adding Gzip here because it's already in the default list of supported compressors
builder.Services.AddResponseCompression();
diff --git a/src/ServiceControl.Audit/Infrastructure/WebApi/RootController.cs b/src/ServiceControl.Audit/Infrastructure/WebApi/RootController.cs
index 064122234d..5e3f466de3 100644
--- a/src/ServiceControl.Audit/Infrastructure/WebApi/RootController.cs
+++ b/src/ServiceControl.Audit/Infrastructure/WebApi/RootController.cs
@@ -1,6 +1,7 @@
namespace ServiceControl.Audit.Infrastructure.WebApi
{
using Configuration;
+ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Settings;
@@ -14,6 +15,8 @@ public RootController(Settings settings)
this.settings = settings;
}
+ // This endpoint is used for health checks by the primary instance. As its a service-to-service call, it needs to be anonymous.
+ [AllowAnonymous]
[Route("")]
[HttpGet]
public OkObjectResult Urls()
@@ -40,6 +43,8 @@ public OkObjectResult Urls()
return Ok(model);
}
+ // This endpoint is used by the primary instance to get the config of remotes. As its a service-to-service call, it needs to be anonymous.
+ [AllowAnonymous]
[Route("instance-info")]
[Route("configuration")]
[HttpGet]
diff --git a/src/ServiceControl.Audit/WebApplicationExtensions.cs b/src/ServiceControl.Audit/WebApplicationExtensions.cs
index e12b2a8465..76785dd77d 100644
--- a/src/ServiceControl.Audit/WebApplicationExtensions.cs
+++ b/src/ServiceControl.Audit/WebApplicationExtensions.cs
@@ -2,13 +2,16 @@ namespace ServiceControl.Audit;
using Infrastructure.WebApi;
using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.HttpOverrides;
+using ServiceControl.Hosting.ForwardedHeaders;
+using ServiceControl.Hosting.Https;
+using ServiceControl.Infrastructure;
public static class WebApplicationExtensions
{
- public static void UseServiceControlAudit(this WebApplication app)
+ public static void UseServiceControlAudit(this WebApplication app, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings)
{
- app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All });
+ app.UseServiceControlForwardedHeaders(forwardedHeadersSettings);
+ app.UseServiceControlHttps(httpsSettings);
app.UseResponseCompression();
app.UseMiddleware();
app.UseHttpLogging();
diff --git a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs
new file mode 100644
index 0000000000..f6bf0497c9
--- /dev/null
+++ b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs
@@ -0,0 +1,145 @@
+namespace ServiceControl.Hosting.Auth
+{
+ using System;
+ using System.Text.Json;
+ using System.Threading.Tasks;
+ using Microsoft.AspNetCore.Authentication.JwtBearer;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.Extensions.DependencyInjection;
+ using Microsoft.Extensions.Hosting;
+ using Microsoft.IdentityModel.Tokens;
+ using ServiceControl.Infrastructure;
+
+ public static class HostApplicationBuilderExtensions
+ {
+ public static void AddServiceControlAuthentication(this IHostApplicationBuilder hostBuilder, OpenIdConnectSettings oidcSettings)
+ {
+ // Authentication is disabled by default
+ if (!oidcSettings.Enabled)
+ {
+ return;
+ }
+
+ _ = hostBuilder.Services.AddAuthentication(options =>
+ {
+ options.DefaultScheme = "Bearer";
+ options.DefaultChallengeScheme = "Bearer";
+ })
+ .AddJwtBearer("Bearer", options =>
+ {
+ options.Authority = oidcSettings.Authority;
+ // Configure token validation parameters
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = oidcSettings.ValidateIssuer,
+ ValidateAudience = oidcSettings.ValidateAudience,
+ ValidateLifetime = oidcSettings.ValidateLifetime,
+ ValidateIssuerSigningKey = oidcSettings.ValidateIssuerSigningKey,
+ ValidAudience = oidcSettings.Audience,
+ ClockSkew = TimeSpan.FromMinutes(5) // Allow 5 minutes clock skew
+ };
+ options.RequireHttpsMetadata = oidcSettings.RequireHttpsMetadata;
+ // Don't map inbound claims to legacy Microsoft claim types
+ options.MapInboundClaims = false;
+
+ // Custom error response handling for better client experience
+ options.Events = new JwtBearerEvents
+ {
+ OnAuthenticationFailed = context =>
+ {
+ if (context.Exception is SecurityTokenExpiredException)
+ {
+ context.Response.Headers.Append("X-Token-Expired", "true");
+ }
+ return Task.CompletedTask;
+ },
+ OnChallenge = context =>
+ {
+ // Skip if response already started or already handled
+ if (context.Response.HasStarted || context.Handled)
+ {
+ return Task.CompletedTask;
+ }
+
+ context.HandleResponse();
+ context.Response.StatusCode = 401;
+ context.Response.ContentType = "application/json";
+
+ var errorResponse = new AuthErrorResponse
+ {
+ Error = "unauthorized",
+ Message = GetErrorMessage(context)
+ };
+
+ return context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse, JsonSerializerOptions));
+ },
+ OnForbidden = context =>
+ {
+ // Skip if response already started
+ if (context.Response.HasStarted)
+ {
+ return Task.CompletedTask;
+ }
+
+ context.Response.StatusCode = 403;
+ context.Response.ContentType = "application/json";
+
+ var errorResponse = new AuthErrorResponse
+ {
+ Error = "forbidden",
+ Message = "You do not have permission to access this resource."
+ };
+
+ return context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse, JsonSerializerOptions));
+ }
+ };
+ });
+
+ _ = hostBuilder.Services.AddAuthorization(configure =>
+ configure.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
+ .RequireAuthenticatedUser()
+ .Build());
+ }
+
+ static string GetErrorMessage(JwtBearerChallengeContext context)
+ {
+ if (context.AuthenticateFailure is SecurityTokenExpiredException)
+ {
+ return "The token has expired. Please obtain a new token and retry.";
+ }
+
+ if (context.AuthenticateFailure is SecurityTokenInvalidSignatureException)
+ {
+ return "The token signature is invalid.";
+ }
+
+ if (context.AuthenticateFailure is SecurityTokenInvalidAudienceException)
+ {
+ return "The token audience is invalid.";
+ }
+
+ if (context.AuthenticateFailure is SecurityTokenInvalidIssuerException)
+ {
+ return "The token issuer is invalid.";
+ }
+
+ if (context.AuthenticateFailure != null)
+ {
+ return "The token is invalid.";
+ }
+
+ return "Authentication required. Please provide a valid Bearer token.";
+ }
+
+ static readonly JsonSerializerOptions JsonSerializerOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+ }
+
+ class AuthErrorResponse
+ {
+ public string Error { get; set; }
+ public string Message { get; set; }
+ }
+}
diff --git a/src/ServiceControl.Hosting/Auth/WebApplicationExtensions.cs b/src/ServiceControl.Hosting/Auth/WebApplicationExtensions.cs
new file mode 100644
index 0000000000..68005c40b1
--- /dev/null
+++ b/src/ServiceControl.Hosting/Auth/WebApplicationExtensions.cs
@@ -0,0 +1,18 @@
+namespace ServiceControl.Hosting.Auth
+{
+ using Microsoft.AspNetCore.Builder;
+
+ public static class WebApplicationExtensions
+ {
+ public static void UseServiceControlAuthentication(this WebApplication app, bool authenticationEnabled = false)
+ {
+ if (!authenticationEnabled)
+ {
+ return;
+ }
+
+ app.UseAuthentication();
+ app.UseAuthorization();
+ }
+ }
+}
diff --git a/src/ServiceControl.Hosting/ForwardedHeaders/WebApplicationExtensions.cs b/src/ServiceControl.Hosting/ForwardedHeaders/WebApplicationExtensions.cs
new file mode 100644
index 0000000000..17f79ba923
--- /dev/null
+++ b/src/ServiceControl.Hosting/ForwardedHeaders/WebApplicationExtensions.cs
@@ -0,0 +1,87 @@
+namespace ServiceControl.Hosting.ForwardedHeaders;
+
+using System.Linq;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.Extensions.Hosting;
+using ServiceControl.Infrastructure;
+
+public static class WebApplicationExtensions
+{
+ public static void UseServiceControlForwardedHeaders(this WebApplication app, ForwardedHeadersSettings settings)
+ {
+ // Register debug endpoint first (before early return) so it's always available in Development
+ if (app.Environment.IsDevelopment())
+ {
+ _ = app.MapGet("/debug/request-info", (HttpContext context) =>
+ {
+ var remoteIp = context.Connection.RemoteIpAddress;
+
+ // Processed values (after ForwardedHeaders middleware, if enabled)
+ var scheme = context.Request.Scheme;
+ var host = context.Request.Host.ToString();
+ var remoteIpAddress = remoteIp?.ToString();
+
+ // Raw forwarded headers (what remains after middleware processing)
+ // Note: When ForwardedHeaders middleware processes headers from a trusted proxy,
+ // it consumes (removes) them from the request headers
+ var xForwardedFor = context.Request.Headers["X-Forwarded-For"].ToString();
+ var xForwardedProto = context.Request.Headers["X-Forwarded-Proto"].ToString();
+ var xForwardedHost = context.Request.Headers["X-Forwarded-Host"].ToString();
+
+ // Configuration
+ var knownProxies = settings.KnownProxies.Select(p => p.ToString()).ToArray();
+ var knownNetworks = settings.KnownNetworks.ToArray();
+
+ return new
+ {
+ processed = new { scheme, host, remoteIpAddress },
+ rawHeaders = new { xForwardedFor, xForwardedProto, xForwardedHost },
+ configuration = new
+ {
+ enabled = settings.Enabled,
+ trustAllProxies = settings.TrustAllProxies,
+ knownProxies,
+ knownNetworks
+ }
+ };
+ });
+ }
+
+ if (!settings.Enabled)
+ {
+ return;
+ }
+
+ var options = new ForwardedHeadersOptions
+ {
+ ForwardedHeaders = ForwardedHeaders.All
+ };
+
+ // Clear default loopback-only restrictions
+ options.KnownProxies.Clear();
+ options.KnownNetworks.Clear();
+
+ if (settings.TrustAllProxies)
+ {
+ // Trust all proxies: remove hop limit
+ options.ForwardLimit = null;
+ }
+ else
+ {
+ // Only trust explicitly configured proxies and networks
+ foreach (var proxy in settings.KnownProxies)
+ {
+ options.KnownProxies.Add(proxy);
+ }
+
+ foreach (var network in settings.KnownNetworks)
+ {
+ options.KnownNetworks.Add(IPNetwork.Parse(network));
+ }
+ }
+
+ app.UseForwardedHeaders(options);
+ }
+}
diff --git a/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs
new file mode 100644
index 0000000000..af7e63e938
--- /dev/null
+++ b/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs
@@ -0,0 +1,53 @@
+namespace ServiceControl.Hosting.Https;
+
+using System;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.HttpsPolicy;
+using Microsoft.Extensions.DependencyInjection;
+using ServiceControl.Infrastructure;
+
+public static class HostApplicationBuilderExtensions
+{
+ public static void AddServiceControlHttps(this WebApplicationBuilder hostBuilder, HttpsSettings settings)
+ {
+ if (settings.EnableHsts)
+ {
+ hostBuilder.Services.Configure(options =>
+ {
+ options.MaxAge = TimeSpan.FromSeconds(settings.HstsMaxAgeSeconds);
+ options.IncludeSubDomains = settings.HstsIncludeSubDomains;
+ });
+ }
+
+ if (settings.RedirectHttpToHttps && settings.HttpsPort.HasValue)
+ {
+ hostBuilder.Services.AddHttpsRedirection(options =>
+ {
+ options.HttpsPort = settings.HttpsPort.Value;
+ });
+ }
+
+ if (settings.Enabled)
+ {
+ hostBuilder.WebHost.ConfigureKestrel(kestrel =>
+ {
+ kestrel.ConfigureHttpsDefaults(httpsOptions =>
+ {
+ httpsOptions.ServerCertificate = LoadCertificate(settings);
+ });
+ });
+ }
+ }
+
+ static X509Certificate2 LoadCertificate(HttpsSettings settings)
+ {
+ if (string.IsNullOrEmpty(settings.CertificatePassword))
+ {
+ return new X509Certificate2(settings.CertificatePath);
+ }
+
+ return new X509Certificate2(settings.CertificatePath, settings.CertificatePassword);
+ }
+}
diff --git a/src/ServiceControl.Hosting/Https/WebApplicationExtensions.cs b/src/ServiceControl.Hosting/Https/WebApplicationExtensions.cs
new file mode 100644
index 0000000000..1123a9b3a8
--- /dev/null
+++ b/src/ServiceControl.Hosting/Https/WebApplicationExtensions.cs
@@ -0,0 +1,21 @@
+namespace ServiceControl.Hosting.Https;
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Hosting;
+using ServiceControl.Infrastructure;
+
+public static class WebApplicationExtensions
+{
+ public static void UseServiceControlHttps(this WebApplication app, HttpsSettings settings)
+ {
+ if (settings.EnableHsts && !app.Environment.IsDevelopment())
+ {
+ app.UseHsts();
+ }
+
+ if (settings.RedirectHttpToHttps)
+ {
+ app.UseHttpsRedirection();
+ }
+ }
+}
diff --git a/src/ServiceControl.Hosting/ServiceControl.Hosting.csproj b/src/ServiceControl.Hosting/ServiceControl.Hosting.csproj
index 074686312c..bf3d228fcf 100644
--- a/src/ServiceControl.Hosting/ServiceControl.Hosting.csproj
+++ b/src/ServiceControl.Hosting/ServiceControl.Hosting.csproj
@@ -5,8 +5,18 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ServiceControl.Infrastructure/CorsSettings.cs b/src/ServiceControl.Infrastructure/CorsSettings.cs
new file mode 100644
index 0000000000..360abe726c
--- /dev/null
+++ b/src/ServiceControl.Infrastructure/CorsSettings.cs
@@ -0,0 +1,92 @@
+namespace ServiceControl.Infrastructure;
+
+using System;
+using System.Collections.Generic;
+using Microsoft.Extensions.Logging;
+using ServiceControl.Configuration;
+
+public class CorsSettings
+{
+ readonly ILogger logger = LoggerUtil.CreateStaticLogger();
+
+ public CorsSettings(SettingsRootNamespace rootNamespace)
+ {
+ // Default to allowing any origin for backwards compatibility
+ AllowAnyOrigin = SettingsReader.Read(rootNamespace, "Cors.AllowAnyOrigin", true);
+
+ var allowedOriginsValue = SettingsReader.Read(rootNamespace, "Cors.AllowedOrigins");
+ if (!string.IsNullOrWhiteSpace(allowedOriginsValue))
+ {
+ AllowedOrigins = ParseOrigins(allowedOriginsValue);
+
+ // If specific origins are configured, disable AllowAnyOrigin
+ if (AllowedOrigins.Count > 0 && AllowAnyOrigin)
+ {
+ logger.LogInformation("Cors.AllowedOrigins configured, setting AllowAnyOrigin to false");
+ AllowAnyOrigin = false;
+ }
+ }
+
+ LogConfiguration();
+ }
+
+ ///
+ /// When true, allows requests from any origin. Default is true for backwards compatibility.
+ ///
+ public bool AllowAnyOrigin { get; private set; }
+
+ ///
+ /// List of specific origins to allow when AllowAnyOrigin is false.
+ ///
+ public List AllowedOrigins { get; } = [];
+
+ List ParseOrigins(string value)
+ {
+ var origins = new List();
+ var parts = value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ foreach (var part in parts)
+ {
+ if (Uri.TryCreate(part, UriKind.Absolute, out var uri))
+ {
+ // Normalize: use origin format (scheme://host:port)
+ var origin = $"{uri.Scheme}://{uri.Authority}";
+ origins.Add(origin);
+ }
+ else
+ {
+ logger.LogWarning("Invalid origin URL in Cors.AllowedOrigins: '{InvalidOrigin}'", part);
+ }
+ }
+
+ return origins;
+ }
+
+ void LogConfiguration()
+ {
+ if (AllowAnyOrigin)
+ {
+ if (AllowedOrigins.Count > 0)
+ {
+ // This shouldn't happen due to the logic in the constructor, but log if it does
+ logger.LogWarning("CORS is configured to allow any origin. AllowedOrigins setting will be ignored: {@Settings}",
+ new { AllowAnyOrigin, AllowedOrigins });
+ }
+ else
+ {
+ logger.LogWarning("CORS is configured to allow any origin. Consider configuring Cors.AllowedOrigins for production environments: {@Settings}",
+ new { AllowAnyOrigin });
+ }
+ }
+ else if (AllowedOrigins.Count > 0)
+ {
+ logger.LogInformation("CORS is configured with specific allowed origins: {@Settings}",
+ new { AllowAnyOrigin, AllowedOrigins });
+ }
+ else
+ {
+ logger.LogWarning("CORS is disabled. No origins are allowed and AllowAnyOrigin is false: {@Settings}",
+ new { AllowAnyOrigin, AllowedOrigins });
+ }
+ }
+}
diff --git a/src/ServiceControl.Infrastructure/ForwardedHeadersSettings.cs b/src/ServiceControl.Infrastructure/ForwardedHeadersSettings.cs
new file mode 100644
index 0000000000..df47118136
--- /dev/null
+++ b/src/ServiceControl.Infrastructure/ForwardedHeadersSettings.cs
@@ -0,0 +1,153 @@
+namespace ServiceControl.Infrastructure;
+
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Logging;
+using ServiceControl.Configuration;
+
+public class ForwardedHeadersSettings
+{
+ readonly ILogger logger = LoggerUtil.CreateStaticLogger();
+
+ public ForwardedHeadersSettings(SettingsRootNamespace rootNamespace)
+ {
+ Enabled = SettingsReader.Read(rootNamespace, "ForwardedHeaders.Enabled", true);
+
+ // Default to trusting all proxies for backwards compatibility
+ // Customers can set this to false and configure KnownProxies/KnownNetworks for better security
+ TrustAllProxies = SettingsReader.Read(rootNamespace, "ForwardedHeaders.TrustAllProxies", true);
+
+ var knownProxiesValue = SettingsReader.Read(rootNamespace, "ForwardedHeaders.KnownProxies");
+ if (!string.IsNullOrWhiteSpace(knownProxiesValue))
+ {
+ KnownProxiesRaw = ParseAndValidateIPAddresses(knownProxiesValue);
+ }
+
+ var knownNetworksValue = SettingsReader.Read(rootNamespace, "ForwardedHeaders.KnownNetworks");
+ if (!string.IsNullOrWhiteSpace(knownNetworksValue))
+ {
+ KnownNetworks = ParseNetworks(knownNetworksValue);
+ }
+
+ // If proxies or networks are explicitly configured, disable TrustAllProxies
+ if ((KnownProxiesRaw.Count > 0 || KnownNetworks.Count > 0) && TrustAllProxies)
+ {
+ logger.LogInformation("KnownProxies or KnownNetworks configured, setting TrustAllProxies to false");
+ TrustAllProxies = false;
+ }
+
+ LogConfiguration();
+ }
+
+ public bool Enabled { get; }
+
+ public bool TrustAllProxies { get; private set; }
+
+ // Store as strings for serialization compatibility, parse to IPAddress when needed
+ public List KnownProxiesRaw { get; } = [];
+
+ public List KnownNetworks { get; } = [];
+
+ // Parse IPAddresses on demand to avoid serialization issues
+ [JsonIgnore]
+ public IEnumerable KnownProxies
+ {
+ get
+ {
+ foreach (var raw in KnownProxiesRaw)
+ {
+ if (IPAddress.TryParse(raw, out var address))
+ {
+ yield return address;
+ }
+ }
+ }
+ }
+
+ List ParseAndValidateIPAddresses(string value)
+ {
+ var addresses = new List();
+ var parts = value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ foreach (var part in parts)
+ {
+ if (IPAddress.TryParse(part, out _))
+ {
+ addresses.Add(part);
+ }
+ else
+ {
+ logger.LogWarning("Invalid IP address in ForwardedHeaders.KnownProxies: '{InvalidAddress}'", part);
+ }
+ }
+
+ return addresses;
+ }
+
+ List ParseNetworks(string value)
+ {
+ var networks = new List();
+ var parts = value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ foreach (var part in parts)
+ {
+ // Basic validation - should contain a /
+ if (part.Contains('/'))
+ {
+ networks.Add(part);
+ }
+ else
+ {
+ logger.LogWarning("Invalid network CIDR in ForwardedHeaders.KnownNetworks (expected format: '10.0.0.0/8'): '{InvalidNetwork}'", part);
+ }
+ }
+
+ return networks;
+ }
+
+ void LogConfiguration()
+ {
+ var hasProxyConfig = KnownProxiesRaw.Count > 0 || KnownNetworks.Count > 0;
+
+ if (!Enabled)
+ {
+ if (hasProxyConfig || TrustAllProxies)
+ {
+ logger.LogWarning("Forwarded headers processing is disabled. Proxy configuration settings will be ignored: {@Settings}",
+ new { Enabled, TrustAllProxies, KnownProxies = KnownProxiesRaw, KnownNetworks });
+ }
+ else
+ {
+ logger.LogInformation("Forwarded headers processing is disabled");
+ }
+ return;
+ }
+
+ if (TrustAllProxies)
+ {
+ if (hasProxyConfig)
+ {
+ // This shouldn't happen due to constructor logic, but log if it does
+ logger.LogWarning("Forwarded headers is configured to trust all proxies. KnownProxies and KnownNetworks settings will be ignored: {@Settings}",
+ new { Enabled, TrustAllProxies, KnownProxies = KnownProxiesRaw, KnownNetworks });
+ }
+ else
+ {
+ logger.LogWarning("Forwarded headers is configured to trust all proxies. Any client can spoof X-Forwarded-* headers. Consider configuring KnownProxies or KnownNetworks for production environments: {@Settings}",
+ new { Enabled, TrustAllProxies });
+ }
+ }
+ else if (hasProxyConfig)
+ {
+ logger.LogInformation("Forwarded headers is configured with specific trusted proxies: {@Settings}",
+ new { Enabled, TrustAllProxies, KnownProxies = KnownProxiesRaw, KnownNetworks });
+ }
+ else
+ {
+ logger.LogWarning("Forwarded headers is enabled but no trusted proxies are configured. X-Forwarded-* headers will not be processed: {@Settings}",
+ new { Enabled, TrustAllProxies, KnownProxies = KnownProxiesRaw, KnownNetworks });
+ }
+ }
+}
diff --git a/src/ServiceControl.Infrastructure/HttpsSettings.cs b/src/ServiceControl.Infrastructure/HttpsSettings.cs
new file mode 100644
index 0000000000..bcbc257080
--- /dev/null
+++ b/src/ServiceControl.Infrastructure/HttpsSettings.cs
@@ -0,0 +1,149 @@
+namespace ServiceControl.Infrastructure;
+
+using System;
+using System.IO;
+using Microsoft.Extensions.Logging;
+using ServiceControl.Configuration;
+
+public class HttpsSettings
+{
+ readonly ILogger logger = LoggerUtil.CreateStaticLogger();
+
+ public HttpsSettings(SettingsRootNamespace rootNamespace)
+ {
+ // Kestrel HTTPS - disabled by default for backwards compatibility
+ Enabled = SettingsReader.Read(rootNamespace, "Https.Enabled", false);
+
+ if (Enabled)
+ {
+ CertificatePath = SettingsReader.Read(rootNamespace, "Https.CertificatePath");
+ CertificatePassword = SettingsReader.Read(rootNamespace, "Https.CertificatePassword");
+
+ ValidateCertificateConfiguration();
+ }
+
+ // HTTPS redirection - disabled by default for backwards compatibility
+ RedirectHttpToHttps = SettingsReader.Read(rootNamespace, "Https.RedirectHttpToHttps", false);
+ HttpsPort = SettingsReader.Read(rootNamespace, "Https.Port", null);
+
+ // HSTS - disabled by default, only applies in non-development environments
+ EnableHsts = SettingsReader.Read(rootNamespace, "Https.EnableHsts", false);
+ HstsMaxAgeSeconds = SettingsReader.Read(rootNamespace, "Https.HstsMaxAgeSeconds", 31536000); // 1 year default
+ HstsIncludeSubDomains = SettingsReader.Read(rootNamespace, "Https.HstsIncludeSubDomains", false);
+
+ LogConfiguration();
+ }
+
+ ///
+ /// When true, Kestrel will be configured to listen on HTTPS using the specified certificate.
+ ///
+ public bool Enabled { get; }
+
+ ///
+ /// Path to the HTTPS certificate file (.pfx or .pem).
+ /// Required when Https.Enabled is true.
+ ///
+ public string CertificatePath { get; }
+
+ ///
+ /// Password for the HTTPS certificate.
+ /// Can be null for certificates without a password.
+ ///
+ public string CertificatePassword { get; }
+
+ ///
+ /// When true, HTTP requests will be redirected to HTTPS.
+ /// Requires HTTPS to be properly configured. Default is false.
+ ///
+ public bool RedirectHttpToHttps { get; }
+
+ ///
+ /// The port to redirect HTTPS requests to. If not specified, uses the default HTTPS port (443).
+ /// Only used when RedirectHttpToHttps is true.
+ ///
+ public int? HttpsPort { get; }
+
+ ///
+ /// When true, enables HTTP Strict Transport Security (HSTS) headers.
+ /// HSTS instructs browsers to only access the site via HTTPS. Default is false.
+ /// Only applies in non-development environments.
+ ///
+ public bool EnableHsts { get; }
+
+ ///
+ /// The max-age value for the HSTS header in seconds. Default is 31536000 (1 year).
+ /// Only used when EnableHsts is true.
+ ///
+ public int HstsMaxAgeSeconds { get; }
+
+ ///
+ /// When true, includes subdomains in the HSTS policy. Default is false.
+ /// Only used when EnableHsts is true.
+ ///
+ public bool HstsIncludeSubDomains { get; }
+
+ void ValidateCertificateConfiguration()
+ {
+ if (string.IsNullOrWhiteSpace(CertificatePath))
+ {
+ var message = "Https.CertificatePath is required when HTTPS is enabled. Please specify the path to a valid HTTPS certificate file (.pfx or .pem)";
+ logger.LogCritical(message);
+ throw new InvalidOperationException(message);
+ }
+
+ if (!File.Exists(CertificatePath))
+ {
+ var message = $"Https.CertificatePath does not exist. Current value: '{CertificatePath}'";
+ logger.LogCritical(message);
+ throw new InvalidOperationException(message);
+ }
+ }
+
+ void LogConfiguration()
+ {
+ var hasRedirectConfig = RedirectHttpToHttps || HttpsPort.HasValue;
+ var hasHstsConfig = EnableHsts || HstsMaxAgeSeconds != 31536000 || HstsIncludeSubDomains;
+
+ if (!Enabled)
+ {
+ if (hasRedirectConfig || hasHstsConfig)
+ {
+ logger.LogWarning("HTTPS is disabled. Redirect and HSTS settings will be ignored: {@Settings}",
+ new
+ {
+ Enabled,
+ RedirectHttpToHttps,
+ HttpsPort,
+ EnableHsts,
+ HstsMaxAgeSeconds,
+ HstsIncludeSubDomains
+ });
+ }
+ else
+ {
+ logger.LogInformation("HTTPS is disabled");
+ }
+ return;
+ }
+
+ // HTTPS is enabled - log all settings
+ logger.LogInformation("HTTPS is enabled: {@Settings}",
+ new
+ {
+ Enabled,
+ CertificatePath,
+ HasCertificatePassword = !string.IsNullOrEmpty(CertificatePassword),
+ RedirectHttpToHttps,
+ HttpsPort,
+ EnableHsts,
+ HstsMaxAgeSeconds,
+ HstsIncludeSubDomains
+ });
+
+ // Warn about potential misconfigurations
+ if (RedirectHttpToHttps && !EnableHsts)
+ {
+ logger.LogWarning("HTTPS redirect is enabled but HSTS is disabled. Consider enabling Https.EnableHsts for better security");
+ }
+ }
+}
diff --git a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs
new file mode 100644
index 0000000000..c06fe20e64
--- /dev/null
+++ b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs
@@ -0,0 +1,281 @@
+namespace ServiceControl.Infrastructure;
+
+using System;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Logging;
+using ServiceControl.Configuration;
+
+///
+/// Configuration settings for OpenID Connect (OIDC) authentication.
+/// When enabled, all API endpoints require a valid JWT Bearer token unless marked with [AllowAnonymous].
+///
+public class OpenIdConnectSettings
+{
+ readonly ILogger logger = LoggerUtil.CreateStaticLogger();
+
+ ///
+ /// Initializes OpenID Connect settings by reading configuration values from the SettingsReader.
+ ///
+ /// The settings root namespace (e.g., "ServiceControl", "ServiceControl.Audit").
+ ///
+ /// When true, validates that all required settings are present and logs security warnings
+ /// for any disabled validation flags. Throws an exception if required settings are missing.
+ ///
+ ///
+ /// When true (default), requires ServicePulse-specific settings (ClientId, ApiScopes).
+ /// Set to false for Audit and Monitoring instances which don't serve the ServicePulse UI.
+ ///
+ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateConfiguration, bool requireServicePulseSettings = true)
+ {
+ // Master switch - if disabled, all other authentication settings are ignored
+ Enabled = SettingsReader.Read(rootNamespace, "Authentication.Enabled", false);
+
+ // Always read all settings so we can log warnings about ignored configuration
+ Authority = SettingsReader.Read(rootNamespace, "Authentication.Authority");
+ Audience = SettingsReader.Read(rootNamespace, "Authentication.Audience");
+ ValidateIssuer = SettingsReader.Read(rootNamespace, "Authentication.ValidateIssuer", true);
+ ValidateAudience = SettingsReader.Read(rootNamespace, "Authentication.ValidateAudience", true);
+ ValidateLifetime = SettingsReader.Read(rootNamespace, "Authentication.ValidateLifetime", true);
+ ValidateIssuerSigningKey = SettingsReader.Read(rootNamespace, "Authentication.ValidateIssuerSigningKey", true);
+ RequireHttpsMetadata = SettingsReader.Read(rootNamespace, "Authentication.RequireHttpsMetadata", true);
+
+ // ServicePulse settings are only relevant for the primary ServiceControl instance
+ // which serves the OIDC configuration endpoint that ServicePulse uses for login
+ if (requireServicePulseSettings)
+ {
+ ServicePulseClientId = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.ClientId");
+ ServicePulseApiScopes = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.ApiScopes");
+ ServicePulseAuthority = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.Authority");
+ }
+
+ if (validateConfiguration)
+ {
+ Validate(requireServicePulseSettings);
+ }
+ }
+
+ ///
+ /// Master switch for authentication. When false, all other authentication settings are ignored
+ /// and all API endpoints are accessible without authentication.
+ ///
+ [JsonPropertyName("enabled")]
+ public bool Enabled { get; }
+
+ ///
+ /// The OpenID Connect authority URL (issuer). This is the base URL of the identity provider
+ /// that issues tokens (e.g., "https://login.microsoftonline.com/{tenant-id}/v2.0" for Azure AD).
+ /// The OIDC discovery document is fetched from {Authority}/.well-known/openid-configuration.
+ ///
+ [JsonPropertyName("authority")]
+ public string Authority { get; }
+
+ ///
+ /// The expected audience claim in the JWT token. Tokens must contain this value in their "aud" claim
+ /// to be considered valid. Typically set to the API identifier or application ID.
+ ///
+ [JsonPropertyName("audience")]
+ public string Audience { get; }
+
+ ///
+ /// When true, validates that the token's issuer matches the configured authority.
+ /// Disabling this allows tokens from any issuer (security warning logged).
+ ///
+ [JsonPropertyName("validateIssuer")]
+ public bool ValidateIssuer { get; }
+
+ ///
+ /// When true, validates that the token's audience matches the configured audience.
+ /// Disabling this allows tokens intended for other applications (security warning logged).
+ ///
+ [JsonPropertyName("validateAudience")]
+ public bool ValidateAudience { get; }
+
+ ///
+ /// When true, validates that the token has not expired based on the "exp" claim.
+ /// Disabling this allows expired tokens to be accepted (security warning logged).
+ ///
+ [JsonPropertyName("validateLifetime")]
+ public bool ValidateLifetime { get; }
+
+ ///
+ /// When true, validates the token's cryptographic signature using keys from the authority's JWKS endpoint.
+ /// Disabling this is a serious security risk as it allows forged tokens (security warning logged).
+ ///
+ [JsonPropertyName("validateIssuerSigningKey")]
+ public bool ValidateIssuerSigningKey { get; }
+
+ ///
+ /// When true, requires the authority URL to use HTTPS. Set to false only for local development
+ /// with HTTP identity providers (not recommended for production).
+ ///
+ [JsonPropertyName("requireHttpsMetadata")]
+ public bool RequireHttpsMetadata { get; }
+
+ ///
+ /// Optional override for the authority URL that ServicePulse should use for authentication.
+ /// If not specified, ServicePulse uses the main Authority value.
+ ///
+ [JsonPropertyName("servicePulseAuthority")]
+ public string ServicePulseAuthority { get; }
+
+ ///
+ /// The OAuth client ID that ServicePulse should use when initiating the authentication flow.
+ /// Required on the primary ServiceControl instance when authentication is enabled.
+ ///
+ [JsonPropertyName("servicePulseClientId")]
+ public string ServicePulseClientId { get; }
+
+ ///
+ /// Space-separated list of API scopes that ServicePulse should request during authentication.
+ /// Required on the primary ServiceControl instance when authentication is enabled.
+ ///
+ [JsonPropertyName("servicePulseApiScopes")]
+ public string ServicePulseApiScopes { get; }
+
+ ///
+ /// Validates the authentication configuration, ensuring required settings are present
+ /// and logging warnings for any security-related settings that are disabled.
+ ///
+ ///
+ /// When true, also validates that ServicePulse settings (ClientId, ApiScopes) are provided.
+ ///
+ /// Thrown when required settings are missing or invalid.
+ void Validate(bool requireServicePulseSettings)
+ {
+ if (!Enabled)
+ {
+ LogDisabledConfiguration(requireServicePulseSettings);
+ return;
+ }
+
+ ValidateEnabledConfiguration(requireServicePulseSettings);
+
+ logger.LogInformation("Authentication is enabled: {@Settings}",
+ new
+ {
+ Authority,
+ Audience,
+ ValidateIssuer,
+ ValidateAudience,
+ ValidateLifetime,
+ ValidateIssuerSigningKey,
+ RequireHttpsMetadata,
+ ServicePulseClientId = requireServicePulseSettings ? ServicePulseClientId : null,
+ ServicePulseAuthority = requireServicePulseSettings ? ServicePulseAuthority : null,
+ ServicePulseApiScopes = requireServicePulseSettings ? ServicePulseApiScopes : null
+ });
+ }
+
+ void LogDisabledConfiguration(bool requireServicePulseSettings)
+ {
+ // Check if any settings are configured but will be ignored because auth is disabled
+ var hasIgnoredSettings =
+ !string.IsNullOrWhiteSpace(Authority) ||
+ !string.IsNullOrWhiteSpace(Audience) ||
+ !ValidateIssuer ||
+ !ValidateAudience ||
+ !ValidateLifetime ||
+ !ValidateIssuerSigningKey ||
+ !RequireHttpsMetadata ||
+ (requireServicePulseSettings && !string.IsNullOrWhiteSpace(ServicePulseClientId)) ||
+ (requireServicePulseSettings && !string.IsNullOrWhiteSpace(ServicePulseApiScopes)) ||
+ (requireServicePulseSettings && !string.IsNullOrWhiteSpace(ServicePulseAuthority));
+
+ if (hasIgnoredSettings)
+ {
+ logger.LogWarning("Authentication is disabled but authentication settings are configured. These settings will be ignored: {@Settings}",
+ new
+ {
+ Authority,
+ Audience,
+ ValidateIssuer,
+ ValidateAudience,
+ ValidateLifetime,
+ ValidateIssuerSigningKey,
+ RequireHttpsMetadata,
+ ServicePulseClientId = requireServicePulseSettings ? ServicePulseClientId : null,
+ ServicePulseAuthority = requireServicePulseSettings ? ServicePulseAuthority : null,
+ ServicePulseApiScopes = requireServicePulseSettings ? ServicePulseApiScopes : null
+ });
+ }
+ else
+ {
+ logger.LogInformation("Authentication is disabled");
+ }
+ }
+
+ void ValidateEnabledConfiguration(bool requireServicePulseSettings)
+ {
+ if (string.IsNullOrWhiteSpace(Authority))
+ {
+ var message = "Authentication.Authority is required when authentication is enabled. Please provide a valid OpenID Connect authority URL (e.g., https://login.microsoftonline.com/{tenant-id}/v2.0)";
+ logger.LogCritical(message);
+ throw new Exception(message);
+ }
+
+ if (!Uri.TryCreate(Authority, UriKind.Absolute, out var authorityUri))
+ {
+ var message = $"Authentication.Authority must be a valid absolute URI. Current value: '{Authority}'";
+ logger.LogCritical(message);
+ throw new Exception(message);
+ }
+
+ if (RequireHttpsMetadata && authorityUri.Scheme != Uri.UriSchemeHttps)
+ {
+ var message = $"Authentication.Authority must use HTTPS when RequireHttpsMetadata is true. Current value: '{Authority}'. Either use HTTPS or set Authentication.RequireHttpsMetadata to false (not recommended for production)";
+ logger.LogCritical(message);
+ throw new Exception(message);
+ }
+
+ if (string.IsNullOrWhiteSpace(Audience))
+ {
+ var message = "Authentication.Audience is required when authentication is enabled. Please provide a valid audience identifier (typically your API identifier or client ID)";
+ logger.LogCritical(message);
+ throw new Exception(message);
+ }
+
+ if (!ValidateIssuer)
+ {
+ logger.LogWarning("Authentication.ValidateIssuer is set to false. This is not recommended for production environments as it allows tokens from untrusted issuers");
+ }
+
+ if (!ValidateAudience)
+ {
+ logger.LogWarning("Authentication.ValidateAudience is set to false. This is not recommended for production environments as it allows tokens intended for other applications");
+ }
+
+ if (!ValidateLifetime)
+ {
+ logger.LogWarning("Authentication.ValidateLifetime is set to false. This is not recommended for production environments as it allows expired tokens to be accepted");
+ }
+
+ if (!ValidateIssuerSigningKey)
+ {
+ logger.LogWarning("Authentication.ValidateIssuerSigningKey is set to false. This is not recommended for production environments as it allows forged tokens to be accepted");
+ }
+
+ if (requireServicePulseSettings)
+ {
+ if (string.IsNullOrWhiteSpace(ServicePulseClientId))
+ {
+ var message = "Authentication.ServicePulse.ClientId is required when authentication is enabled. Please provide the OAuth client ID for ServicePulse";
+ logger.LogCritical(message);
+ throw new Exception(message);
+ }
+
+ if (string.IsNullOrWhiteSpace(ServicePulseApiScopes))
+ {
+ var message = "Authentication.ServicePulse.ApiScopes is required when authentication is enabled. Please provide the API scopes ServicePulse should request";
+ logger.LogCritical(message);
+ throw new Exception(message);
+ }
+
+ if (ServicePulseAuthority != null && !Uri.TryCreate(ServicePulseAuthority, UriKind.Absolute, out _))
+ {
+ var message = $"Authentication.ServicePulse.Authority must be a valid absolute URI. Current value: '{ServicePulseAuthority}'";
+ logger.LogCritical(message);
+ throw new Exception(message);
+ }
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/Cors/When_cors_allows_any_origin.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/Cors/When_cors_allows_any_origin.cs
new file mode 100644
index 0000000000..89ef059748
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/Cors/When_cors_allows_any_origin.cs
@@ -0,0 +1,92 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.Cors
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Cors;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Default CORS Behavior (AllowAnyOrigin = true)
+ /// When AllowAnyOrigin is true (default for backwards compatibility), requests from any origin should be allowed.
+ /// The Access-Control-Allow-Origin header should be "*".
+ ///
+ class When_cors_allows_any_origin : AcceptanceTest
+ {
+ CorsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureCors() =>
+ // Default behavior - allow any origin
+ configuration = new CorsTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithAllowAnyOrigin();
+
+ [TearDown]
+ public void CleanupCors() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_return_wildcard_access_control_allow_origin_header()
+ {
+ HttpResponseMessage response = null;
+ const string testOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: testOrigin,
+ endpoint: "/");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertAllowAnyOrigin(response, testOrigin);
+ }
+
+ [Test]
+ public async Task Should_return_expected_allowed_methods()
+ {
+ HttpResponseMessage response = null;
+ const string testOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendPreflightRequest(
+ HttpClient,
+ origin: testOrigin,
+ requestMethod: "POST");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertAllowedMethods(response, "POST", "GET", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD");
+ }
+
+ [Test]
+ public async Task Should_return_expected_exposed_headers()
+ {
+ HttpResponseMessage response = null;
+ const string testOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: testOrigin,
+ endpoint: "/");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertExposedHeaders(response, "ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/Cors/When_cors_is_disabled.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/Cors/When_cors_is_disabled.cs
new file mode 100644
index 0000000000..83242ce6aa
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/Cors/When_cors_is_disabled.cs
@@ -0,0 +1,72 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.Cors
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Cors;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// CORS Disabled
+ /// When AllowAnyOrigin is false and no AllowedOrigins are configured, CORS is effectively disabled.
+ /// No Access-Control-Allow-Origin header should be returned for any origin.
+ ///
+ class When_cors_is_disabled : AcceptanceTest
+ {
+ CorsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureCors() =>
+ // Disable CORS by setting AllowAnyOrigin to false and not configuring any origins
+ configuration = new CorsTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithCorsDisabled();
+
+ [TearDown]
+ public void CleanupCors() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_not_return_access_control_allow_origin_header()
+ {
+ HttpResponseMessage response = null;
+ const string testOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: testOrigin,
+ endpoint: "/");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertCorsDisabled(response);
+ }
+
+ [Test]
+ public async Task Preflight_request_should_not_return_cors_headers()
+ {
+ HttpResponseMessage response = null;
+ const string testOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendPreflightRequest(
+ HttpClient,
+ origin: testOrigin,
+ requestMethod: "POST");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertCorsDisabled(response);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/Cors/When_request_from_allowed_origin.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/Cors/When_request_from_allowed_origin.cs
new file mode 100644
index 0000000000..5745d8c2b1
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/Cors/When_request_from_allowed_origin.cs
@@ -0,0 +1,93 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.Cors
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Cors;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Request from Allowed Origin
+ /// When a request comes from an origin that is in the AllowedOrigins list,
+ /// the response should include the Access-Control-Allow-Origin header with that specific origin.
+ ///
+ class When_request_from_allowed_origin : AcceptanceTest
+ {
+ CorsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureCors() =>
+ // Configure specific allowed origins
+ configuration = new CorsTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithAllowedOrigins("https://app.example.com,https://admin.example.com");
+
+ [TearDown]
+ public void CleanupCors() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_return_matching_origin_in_access_control_allow_origin_header()
+ {
+ HttpResponseMessage response = null;
+ const string allowedOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: allowedOrigin,
+ endpoint: "/");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertAllowedOrigin(response, allowedOrigin);
+ }
+
+ [Test]
+ public async Task Should_allow_second_configured_origin()
+ {
+ HttpResponseMessage response = null;
+ const string allowedOrigin = "https://admin.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: allowedOrigin,
+ endpoint: "/");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertAllowedOrigin(response, allowedOrigin);
+ }
+
+ [Test]
+ public async Task Preflight_request_should_return_correct_cors_headers()
+ {
+ HttpResponseMessage response = null;
+ const string allowedOrigin = "https://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendPreflightRequest(
+ HttpClient,
+ origin: allowedOrigin,
+ requestMethod: "POST");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertAllowedOrigin(response, allowedOrigin);
+ CorsAssertions.AssertAllowedMethods(response, "POST", "GET", "PUT", "DELETE");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/Cors/When_request_from_disallowed_origin.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/Cors/When_request_from_disallowed_origin.cs
new file mode 100644
index 0000000000..d81384f753
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/Cors/When_request_from_disallowed_origin.cs
@@ -0,0 +1,135 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.Cors
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Cors;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Request from Disallowed Origin
+ /// When a request comes from an origin that is NOT in the AllowedOrigins list,
+ /// the response should NOT include an Access-Control-Allow-Origin header that matches the request origin.
+ ///
+ class When_request_from_disallowed_origin : AcceptanceTest
+ {
+ CorsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureCors() =>
+ // Configure specific allowed origins - the test origin won't be in this list
+ configuration = new CorsTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithAllowedOrigins("https://app.example.com,https://admin.example.com");
+
+ [TearDown]
+ public void CleanupCors() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_not_return_access_control_allow_origin_header_for_disallowed_origin()
+ {
+ HttpResponseMessage response = null;
+ const string disallowedOrigin = "https://malicious.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: disallowedOrigin,
+ endpoint: "/");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertOriginNotAllowed(response, disallowedOrigin);
+ }
+
+ [Test]
+ public async Task Should_not_allow_origin_with_different_scheme()
+ {
+ HttpResponseMessage response = null;
+ // http:// instead of https://
+ const string disallowedOrigin = "http://app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: disallowedOrigin,
+ endpoint: "/");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertOriginNotAllowed(response, disallowedOrigin);
+ }
+
+ [Test]
+ public async Task Should_not_allow_origin_with_different_port()
+ {
+ HttpResponseMessage response = null;
+ // Different port
+ const string disallowedOrigin = "https://app.example.com:8080";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: disallowedOrigin,
+ endpoint: "/");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertOriginNotAllowed(response, disallowedOrigin);
+ }
+
+ [Test]
+ public async Task Should_not_allow_subdomain_when_parent_domain_is_configured()
+ {
+ HttpResponseMessage response = null;
+ // Subdomain of allowed origin
+ const string disallowedOrigin = "https://sub.app.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendRequestWithOrigin(
+ HttpClient,
+ origin: disallowedOrigin,
+ endpoint: "/");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertOriginNotAllowed(response, disallowedOrigin);
+ }
+
+ [Test]
+ public async Task Preflight_request_should_not_allow_disallowed_origin()
+ {
+ HttpResponseMessage response = null;
+ const string disallowedOrigin = "https://malicious.example.com";
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await CorsAssertions.SendPreflightRequest(
+ HttpClient,
+ origin: disallowedOrigin,
+ requestMethod: "POST");
+ return response != null;
+ })
+ .Run();
+
+ CorsAssertions.AssertOriginNotAllowed(response, disallowedOrigin);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs
new file mode 100644
index 0000000000..38d34bafd3
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs
@@ -0,0 +1,62 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// When both KnownProxies and KnownNetworks are configured, matching either grants trust.
+ ///
+ class When_combined_proxies_and_networks_are_configured : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Configure both proxies (that don't match localhost) and networks (that include localhost)
+ // The localhost should match via the networks, proving OR logic
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithKnownProxiesAndNetworks("192.168.1.100", "127.0.0.0/8,::1/128");
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Headers_should_be_applied_when_caller_matches_network_but_not_proxy()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+
+ using (Assert.EnterMultipleScope())
+ {
+ // Verify configuration shows both proxies and networks
+ Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("192.168.1.100"));
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8").Or.Contain("::1/128"));
+ }
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs
new file mode 100644
index 0000000000..8b4acceca8
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs
@@ -0,0 +1,54 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// When forwarded headers processing is disabled, headers should be ignored regardless of trust.
+ ///
+ class When_forwarded_headers_are_disabled : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Disable forwarded headers processing entirely
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithForwardedHeadersDisabled();
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Headers_should_be_ignored_when_disabled()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersIgnoredWhenDisabled(
+ requestInfo,
+ sentXForwardedFor: "203.0.113.50",
+ sentXForwardedProto: "https",
+ sentXForwardedHost: "example.com");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs
new file mode 100644
index 0000000000..21a572a80c
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs
@@ -0,0 +1,43 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// When forwarded headers are sent and TrustAllProxies is true (default), headers should be applied.
+ ///
+ class When_forwarded_headers_are_sent : AcceptanceTest
+ {
+ [Test]
+ public async Task Headers_should_be_applied_when_trust_all_proxies()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWhenTrustAllProxies(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs
new file mode 100644
index 0000000000..8c19ed9689
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs
@@ -0,0 +1,57 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// When KnownNetworks are configured and the caller IP falls within, headers should be applied.
+ ///
+ class When_known_networks_are_configured : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Configure known networks to include localhost CIDR ranges (test server uses localhost)
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithKnownNetworks("127.0.0.0/8,::1/128");
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Headers_should_be_applied_when_caller_matches_known_network()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+
+ // Verify configuration shows known networks
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8").Or.Contain("::1/128"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs
new file mode 100644
index 0000000000..56270fcb7b
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs
@@ -0,0 +1,57 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// When KnownProxies are configured and the caller IP matches, headers should be applied.
+ ///
+ class When_known_proxies_are_configured : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Configure known proxies to include localhost addresses (test server uses localhost)
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithKnownProxies("127.0.0.1,::1");
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Headers_should_be_applied_when_caller_matches_known_proxy()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+
+ // Verify configuration shows known proxies
+ Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("127.0.0.1").Or.Contain("::1"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs
new file mode 100644
index 0000000000..d48a4b961e
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs
@@ -0,0 +1,37 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// When only X-Forwarded-Proto is sent, only scheme should change.
+ ///
+ class When_only_proto_header_is_sent : AcceptanceTest
+ {
+ [Test]
+ public async Task Only_scheme_should_be_changed()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedProto: "https");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertPartialHeadersApplied(requestInfo, expectedScheme: "https");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs
new file mode 100644
index 0000000000..ebac5d18f2
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs
@@ -0,0 +1,47 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// When TrustAllProxies is true and X-Forwarded-For contains multiple IPs (proxy chain),
+ /// the original client IP (first in the chain) should be returned.
+ ///
+ class When_proxy_chain_headers_are_sent : AcceptanceTest
+ {
+ [Test]
+ public async Task Original_client_ip_should_be_returned_when_trust_all_proxies()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // Simulate a proxy chain: client -> proxy1 -> proxy2 -> ServiceControl.Monitoring
+ // X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1
+ // Expected: 203.0.113.50 (original client)
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertProxyChainProcessedWithTrustAllProxies(
+ requestInfo,
+ expectedOriginalClientIp: "203.0.113.50",
+ expectedScheme: "https",
+ expectedHost: "example.com");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs
new file mode 100644
index 0000000000..05cc020bee
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs
@@ -0,0 +1,58 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// When TrustAllProxies=false (known proxies configured), ForwardLimit=1, so only the last proxy IP is processed.
+ ///
+ class When_proxy_chain_headers_are_sent_with_known_proxies : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Configure known proxies to include localhost (test server uses localhost)
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithKnownProxies("127.0.0.1,::1");
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Only_last_proxy_ip_should_be_processed_when_forward_limit_is_one()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // Simulate a proxy chain: client -> proxy1 -> proxy2 -> ServiceControl.Monitoring
+ // X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1
+ // Expected with ForwardLimit=1: 192.168.1.1 (last proxy in chain)
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertProxyChainWithForwardLimitOne(
+ requestInfo,
+ expectedLastProxyIp: "192.168.1.1",
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemainingForwardedFor: "203.0.113.50,10.0.0.1");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs
new file mode 100644
index 0000000000..de20a0efa3
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs
@@ -0,0 +1,39 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// When no forwarded headers are sent, the request values should remain unchanged.
+ ///
+ class When_request_has_no_forwarded_headers : AcceptanceTest
+ {
+ [Test]
+ public async Task Request_values_should_remain_unchanged()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ var result = await this.TryGet("/debug/request-info");
+ if (result.HasResult)
+ {
+ requestInfo = result.Item;
+ return true;
+ }
+ return false;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertDirectAccessWithNoForwardedHeaders(requestInfo);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs
new file mode 100644
index 0000000000..20b74caf84
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs
@@ -0,0 +1,62 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// When KnownNetworks are configured but the caller IP does NOT fall within, headers should be ignored.
+ ///
+ class When_unknown_network_sends_headers : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Configure known networks that do NOT include localhost (test server uses localhost)
+ // This should cause headers to be ignored
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithKnownNetworks("10.0.0.0/8,192.168.0.0/16");
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Headers_should_be_ignored_when_caller_not_in_known_networks()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // Simulate request from IP 203.0.113.1 (TEST-NET-3, not in known networks)
+ // The known networks are 10.0.0.0/8 and 192.168.0.0/16, so this IP should be rejected
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com",
+ testRemoteIp: "203.0.113.1");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersIgnoredWhenProxyNotTrusted(
+ requestInfo,
+ sentXForwardedFor: "203.0.113.50",
+ sentXForwardedProto: "https",
+ sentXForwardedHost: "example.com");
+
+ // Verify configuration shows the networks (203.0.113.1 is NOT in these networks)
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("10.0.0.0/8"));
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("192.168.0.0/16"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs
new file mode 100644
index 0000000000..599106a67e
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs
@@ -0,0 +1,61 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// When KnownProxies are configured but the caller IP does NOT match, headers should be ignored.
+ ///
+ class When_unknown_proxy_sends_headers : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders() =>
+ // Configure a known proxy that does NOT match localhost (test server uses localhost)
+ // This should cause headers to be ignored
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithKnownProxies("192.168.1.100");
+
+ [TearDown]
+ public void CleanupForwardedHeaders() => configuration?.Dispose();
+
+ [Test]
+ public async Task Headers_should_be_ignored_when_caller_not_in_known_proxies()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // Simulate request from IP 203.0.113.1 (TEST-NET-3, not in known proxies)
+ // The known proxy is 192.168.1.100, so this IP should be rejected
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com",
+ testRemoteIp: "203.0.113.1");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersIgnoredWhenProxyNotTrusted(
+ requestInfo,
+ sentXForwardedFor: "203.0.113.50",
+ sentXForwardedProto: "https",
+ sentXForwardedHost: "example.com");
+
+ // Verify configuration shows the trusted proxy (203.0.113.1 is NOT this proxy)
+ Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("192.168.1.100"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/Https/When_hsts_is_configured.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/Https/When_hsts_is_configured.cs
new file mode 100644
index 0000000000..5b3b0da5f0
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/Https/When_hsts_is_configured.cs
@@ -0,0 +1,54 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.Https
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Https;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// HSTS Configuration
+ /// Note: HSTS only applies in non-development environments.
+ /// In acceptance tests (which run in Development mode), HSTS headers are not sent.
+ /// This test verifies that HSTS is correctly NOT applied in development mode.
+ ///
+ class When_hsts_is_configured : AcceptanceTest
+ {
+ HttpsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureHttps() =>
+ configuration = new HttpsTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithHstsEnabled()
+ .WithHstsMaxAge(31536000)
+ .WithHstsIncludeSubDomains();
+
+ [TearDown]
+ public void CleanupHttps() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_not_include_hsts_header_in_development_mode()
+ {
+ // HSTS is intentionally NOT applied in development environments
+ // This is ASP.NET Core's default behavior to prevent HSTS from being cached
+ // by browsers during development, which could cause issues when switching
+ // between HTTP and HTTPS during testing.
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await this.GetRaw("/");
+ return response != null;
+ })
+ .Run();
+
+ HttpsAssertions.AssertNoHstsHeader(response);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/Https/When_https_redirect_is_disabled.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/Https/When_https_redirect_is_disabled.cs
new file mode 100644
index 0000000000..27565bc048
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/Https/When_https_redirect_is_disabled.cs
@@ -0,0 +1,46 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.Https
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Https;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// HTTPS Redirect Disabled (Default)
+ /// When RedirectHttpToHttps is false (default), HTTP requests should not be redirected.
+ ///
+ class When_https_redirect_is_disabled : AcceptanceTest
+ {
+ HttpsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureHttps() =>
+ configuration = new HttpsTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithRedirectHttpToHttpsDisabled();
+
+ [TearDown]
+ public void CleanupHttps() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_not_redirect_http_requests()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await this.GetRaw("/");
+ return response != null;
+ })
+ .Run();
+
+ HttpsAssertions.AssertNoHttpsRedirect(response);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/Https/When_https_redirect_is_enabled.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/Https/When_https_redirect_is_enabled.cs
new file mode 100644
index 0000000000..ebfe0d92e8
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/Https/When_https_redirect_is_enabled.cs
@@ -0,0 +1,47 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.Https
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.Https;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// HTTPS Redirect Enabled
+ /// When RedirectHttpToHttps is true, HTTP requests should be redirected to HTTPS.
+ ///
+ class When_https_redirect_is_enabled : AcceptanceTest
+ {
+ HttpsTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureHttps() =>
+ configuration = new HttpsTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithRedirectHttpToHttps()
+ .WithHttpsPort(443);
+
+ [TearDown]
+ public void CleanupHttps() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_redirect_http_requests_to_https()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await this.GetRaw("/");
+ return response != null;
+ })
+ .Run();
+
+ HttpsAssertions.AssertHttpsRedirect(response);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_disabled.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_disabled.cs
new file mode 100644
index 0000000000..3e568a3643
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_disabled.cs
@@ -0,0 +1,51 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.OpenIdConnect
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.OpenIdConnect;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Authentication Disabled (Default)
+ /// When Authentication.Enabled is false (default), all API endpoints should be accessible
+ /// without authentication. Requests should not require Bearer tokens.
+ ///
+ class When_authentication_is_disabled : AcceptanceTest
+ {
+ OpenIdConnectTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureAuth() =>
+ configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithAuthenticationDisabled();
+
+ [TearDown]
+ public void CleanupAuth() => configuration?.Dispose();
+
+ [Test]
+ public async Task Should_allow_requests_without_authentication()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // Use /monitored-endpoints to test an endpoint that would require auth if enabled
+ response = await OpenIdConnectAssertions.SendRequestWithoutAuth(
+ HttpClient,
+ HttpMethod.Get,
+ "/monitored-endpoints");
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertNoAuthenticationRequired(response);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs
new file mode 100644
index 0000000000..d5912596dd
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs
@@ -0,0 +1,174 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.OpenIdConnect
+{
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.OpenIdConnect;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Authentication Enabled
+ /// When Authentication.Enabled is true, API endpoints should require a valid JWT Bearer token
+ /// unless marked with [AllowAnonymous].
+ ///
+ /// This test uses a mock OIDC server to provide discovery endpoints and signing keys,
+ /// allowing full testing of the JWT Bearer authentication flow.
+ ///
+ class When_authentication_is_enabled : AcceptanceTest
+ {
+ OpenIdConnectTestConfiguration configuration;
+ MockOidcServer mockOidcServer;
+
+ const string TestAudience = "api://test-audience";
+
+ [SetUp]
+ public void ConfigureAuth()
+ {
+ // Start mock OIDC server
+ mockOidcServer = new MockOidcServer(audience: TestAudience);
+ mockOidcServer.Start();
+
+ configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithConfigurationValidationDisabled()
+ .WithAuthenticationEnabled()
+ .WithAuthority(mockOidcServer.Authority)
+ .WithAudience(TestAudience)
+ .WithRequireHttpsMetadata(false);
+ }
+
+ [TearDown]
+ public void CleanupAuth()
+ {
+ configuration?.Dispose();
+ mockOidcServer?.Dispose();
+ }
+
+ [Test]
+ public async Task Should_reject_requests_without_bearer_token()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // Use /monitored-endpoints which does NOT have [AllowAnonymous] so it should require authentication
+ response = await OpenIdConnectAssertions.SendRequestWithoutAuth(
+ HttpClient,
+ HttpMethod.Get,
+ "/monitored-endpoints");
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertUnauthorized(response);
+ }
+
+ [Test]
+ public async Task Should_reject_requests_with_invalid_bearer_token()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
+ HttpClient,
+ HttpMethod.Get,
+ "/monitored-endpoints",
+ "invalid-token-value");
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertUnauthorized(response);
+ }
+
+ [Test]
+ public async Task Should_accept_requests_with_valid_bearer_token()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ var validToken = mockOidcServer.GenerateToken();
+ response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
+ HttpClient,
+ HttpMethod.Get,
+ "/monitored-endpoints",
+ validToken);
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertAuthenticated(response);
+ }
+
+ [Test]
+ public async Task Should_reject_requests_with_expired_token()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ var expiredToken = mockOidcServer.GenerateExpiredToken();
+ response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
+ HttpClient,
+ HttpMethod.Get,
+ "/monitored-endpoints",
+ expiredToken);
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertUnauthorized(response);
+ }
+
+ [Test]
+ public async Task Should_reject_requests_with_wrong_audience()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ var wrongAudienceToken = mockOidcServer.GenerateTokenWithWrongAudience();
+ response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
+ HttpClient,
+ HttpMethod.Get,
+ "/monitored-endpoints",
+ wrongAudienceToken);
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertUnauthorized(response);
+ }
+
+ [Test]
+ public async Task Should_allow_anonymous_access_to_root_endpoint()
+ {
+ HttpResponseMessage response = null;
+
+ _ = await Define()
+ .Done(async ctx =>
+ {
+ // The root endpoint (/) is marked [AllowAnonymous] for discovery
+ response = await OpenIdConnectAssertions.SendRequestWithoutAuth(
+ HttpClient,
+ HttpMethod.Get,
+ "/");
+ return response != null;
+ })
+ .Run();
+
+ OpenIdConnectAssertions.AssertNoAuthenticationRequired(response);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
index 00c545bd97..082a8e6e30 100644
--- a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
@@ -2,6 +2,7 @@ namespace ServiceControl.Monitoring.AcceptanceTests.TestSupport
{
using System;
using System.IO;
+ using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
@@ -16,6 +17,8 @@ namespace ServiceControl.Monitoring.AcceptanceTests.TestSupport
using Microsoft.Extensions.Logging;
using Monitoring;
using NServiceBus;
+ using ServiceControl.Hosting.Auth;
+ using ServiceControl.Hosting.Https;
using NServiceBus.AcceptanceTesting;
using NServiceBus.AcceptanceTesting.Support;
using ServiceControl.Infrastructure;
@@ -96,6 +99,7 @@ async Task InitializeServiceControl(ScenarioContext context)
hostBuilder.Logging.ClearProviders();
hostBuilder.Logging.ConfigureLogging(LogLevel.Information);
+ hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings);
hostBuilder.AddServiceControlMonitoring((criticalErrorContext, cancellationToken) =>
{
var logitem = new ScenarioContext.LogItem
@@ -109,11 +113,30 @@ async Task InitializeServiceControl(ScenarioContext context)
return criticalErrorContext.Stop(cancellationToken);
}, settings, configuration);
hostBuilder.AddServiceControlMonitoringApi();
+ hostBuilder.AddServiceControlHttps(settings.HttpsSettings);
hostBuilder.AddServiceControlMonitoringTesting(settings);
host = hostBuilder.Build();
- host.UseServiceControlMonitoring();
+
+ // Test middleware: Set RemoteIpAddress from X-Test-Remote-IP header
+ // This must run BEFORE UseServiceControlMonitoring (which adds ForwardedHeaders middleware)
+ // so that the ForwardedHeaders middleware can properly check KnownProxies/KnownNetworks
+ _ = host.Use(async (context, next) =>
+ {
+ if (context.Request.Headers.TryGetValue("X-Test-Remote-IP", out var testIpHeader))
+ {
+ var testIpValue = testIpHeader.ToString();
+ if (IPAddress.TryParse(testIpValue, out var testIp))
+ {
+ context.Connection.RemoteIpAddress = testIp;
+ }
+ }
+ await next();
+ });
+
+ host.UseServiceControlAuthentication(settings.OpenIdConnectSettings.Enabled);
+ host.UseServiceControlMonitoring(settings.ForwardedHeadersSettings, settings.HttpsSettings, settings.CorsSettings);
await host.StartAsync();
HttpClient = host.Services.GetRequiredKeyedService(settings.InstanceName).CreateClient();
diff --git a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt
index 070234384e..149ea9d170 100644
--- a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt
+++ b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt
@@ -3,6 +3,39 @@
"LogLevel": "Information",
"LogPath": "C:\\Logs"
},
+ "OpenIdConnectSettings": {
+ "enabled": false,
+ "authority": null,
+ "audience": null,
+ "validateIssuer": true,
+ "validateAudience": true,
+ "validateLifetime": true,
+ "validateIssuerSigningKey": true,
+ "requireHttpsMetadata": true,
+ "servicePulseAuthority": null,
+ "servicePulseClientId": null,
+ "servicePulseApiScopes": null
+ },
+ "ForwardedHeadersSettings": {
+ "Enabled": true,
+ "TrustAllProxies": true,
+ "KnownProxiesRaw": [],
+ "KnownNetworks": []
+ },
+ "HttpsSettings": {
+ "Enabled": false,
+ "CertificatePath": null,
+ "CertificatePassword": null,
+ "RedirectHttpToHttps": false,
+ "HttpsPort": null,
+ "EnableHsts": false,
+ "HstsMaxAgeSeconds": 31536000,
+ "HstsIncludeSubDomains": false
+ },
+ "CorsSettings": {
+ "AllowAnyOrigin": true,
+ "AllowedOrigins": []
+ },
"InstanceName": "Particular.Monitoring",
"TransportType": "NServiceBus.ServiceControlLearningTransport, ServiceControl.Transports.LearningTransport",
"ConnectionString": null,
@@ -13,5 +46,6 @@
"RootUrl": "http://localhost:9999/",
"MaximumConcurrencyLevel": null,
"ServiceControlThroughputDataQueue": "ServiceControl.ThroughputData",
+ "ValidateConfiguration": true,
"ShutdownTimeout": "00:00:05"
}
\ No newline at end of file
diff --git a/src/ServiceControl.Monitoring/App.config b/src/ServiceControl.Monitoring/App.config
index 0a2fa4d478..ab620acab4 100644
--- a/src/ServiceControl.Monitoring/App.config
+++ b/src/ServiceControl.Monitoring/App.config
@@ -22,6 +22,49 @@ These settings are only here so that we can debug ServiceControl while developin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs b/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs
index 340c12fc08..ca648ac222 100644
--- a/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs
+++ b/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs
@@ -5,6 +5,8 @@ namespace ServiceControl.Monitoring
using Infrastructure.WebApi;
using Microsoft.AspNetCore.Builder;
using NServiceBus;
+ using ServiceControl.Hosting.Auth;
+ using ServiceControl.Hosting.Https;
class RunCommand : AbstractCommand
{
@@ -13,11 +15,15 @@ public override async Task Execute(HostArguments args, Settings settings)
var endpointConfiguration = new EndpointConfiguration(settings.InstanceName);
var hostBuilder = WebApplication.CreateBuilder();
+ hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings);
+ hostBuilder.AddServiceControlHttps(settings.HttpsSettings);
hostBuilder.AddServiceControlMonitoring((_, __) => Task.CompletedTask, settings, endpointConfiguration);
hostBuilder.AddServiceControlMonitoringApi();
var app = hostBuilder.Build();
- app.UseServiceControlMonitoring();
+ app.UseServiceControlMonitoring(settings.ForwardedHeadersSettings, settings.HttpsSettings, settings.CorsSettings);
+ app.UseServiceControlAuthentication(authenticationEnabled: settings.OpenIdConnectSettings.Enabled);
+
await app.RunAsync(settings.RootUrl);
}
}
diff --git a/src/ServiceControl.Monitoring/Http/RootController.cs b/src/ServiceControl.Monitoring/Http/RootController.cs
index c22f7f7ff1..f91258f5fb 100644
--- a/src/ServiceControl.Monitoring/Http/RootController.cs
+++ b/src/ServiceControl.Monitoring/Http/RootController.cs
@@ -1,5 +1,6 @@
namespace ServiceControl.Monitoring.Http
{
+ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
@@ -8,6 +9,7 @@ public class RootController : ControllerBase
{
[Route("")]
[HttpGet]
+ [AllowAnonymous] // Root endpoint returns instance metadata and must remain accessible for discovery
public ActionResult Get()
{
var model = new MonitoringInstanceModel
diff --git a/src/ServiceControl.Monitoring/Settings.cs b/src/ServiceControl.Monitoring/Settings.cs
index 3412307042..e668659f89 100644
--- a/src/ServiceControl.Monitoring/Settings.cs
+++ b/src/ServiceControl.Monitoring/Settings.cs
@@ -16,6 +16,15 @@ public Settings(LoggingSettings loggingSettings = null, string transportType = n
{
LoggingSettings = loggingSettings ?? new(SettingsRootNamespace);
+ // OIDC authentication (ServicePulse URLs not required for monitoring)
+ OpenIdConnectSettings = new OpenIdConnectSettings(SettingsRootNamespace, ValidateConfiguration, requireServicePulseSettings: false);
+ // X-Forwarded-* header processing for reverse proxy scenarios
+ ForwardedHeadersSettings = new ForwardedHeadersSettings(SettingsRootNamespace);
+ // HTTPS/TLS and HSTS configuration
+ HttpsSettings = new HttpsSettings(SettingsRootNamespace);
+ // Cross-origin resource sharing policy
+ CorsSettings = new CorsSettings(SettingsRootNamespace);
+
// Overwrite the instance name if it is specified in ENVVAR, reg, or config file
InstanceName = SettingsReader.Read(SettingsRootNamespace, "InstanceName", InstanceName);
@@ -49,6 +58,14 @@ public Settings(LoggingSettings loggingSettings = null, string transportType = n
public LoggingSettings LoggingSettings { get; }
+ public OpenIdConnectSettings OpenIdConnectSettings { get; }
+
+ public ForwardedHeadersSettings ForwardedHeadersSettings { get; }
+
+ public HttpsSettings HttpsSettings { get; }
+
+ public CorsSettings CorsSettings { get; }
+
public string InstanceName { get; init; } = DEFAULT_INSTANCE_NAME;
public string TransportType { get; set; }
@@ -63,12 +80,14 @@ public Settings(LoggingSettings loggingSettings = null, string transportType = n
public TimeSpan EndpointUptimeGracePeriod { get; set; }
- public string RootUrl => $"http://{HttpHostName}:{HttpPort}/";
+ public string RootUrl => $"{(HttpsSettings.Enabled ? "https" : "http")}://{HttpHostName}:{HttpPort}/";
public int? MaximumConcurrencyLevel { get; set; }
public string ServiceControlThroughputDataQueue { get; set; }
+ public bool ValidateConfiguration => SettingsReader.Read(SettingsRootNamespace, "ValidateConfig", true);
+
// The default value is set to the maximum allowed time by the most
// restrictive hosting platform, which is Linux containers. Linux
// containers allow for a maximum of 10 seconds. We set it to 5 to
diff --git a/src/ServiceControl.Monitoring/WebApplicationExtensions.cs b/src/ServiceControl.Monitoring/WebApplicationExtensions.cs
index db4e2d3a3e..3bfd53e8f6 100644
--- a/src/ServiceControl.Monitoring/WebApplicationExtensions.cs
+++ b/src/ServiceControl.Monitoring/WebApplicationExtensions.cs
@@ -1,22 +1,34 @@
namespace ServiceControl.Monitoring.Infrastructure;
using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.HttpOverrides;
+using ServiceControl.Hosting.ForwardedHeaders;
+using ServiceControl.Hosting.Https;
+using ServiceControl.Infrastructure;
public static class WebApplicationExtensions
{
- public static void UseServiceControlMonitoring(this WebApplication appBuilder)
+ public static void UseServiceControlMonitoring(this WebApplication appBuilder, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings, CorsSettings corsSettings)
{
- appBuilder.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All });
+ appBuilder.UseServiceControlForwardedHeaders(forwardedHeadersSettings);
+ appBuilder.UseServiceControlHttps(httpsSettings);
appBuilder.UseHttpLogging();
appBuilder.UseCors(policyBuilder =>
{
- policyBuilder.AllowAnyOrigin();
+ if (corsSettings.AllowAnyOrigin)
+ {
+ policyBuilder.AllowAnyOrigin();
+ }
+ else if (corsSettings.AllowedOrigins.Count > 0)
+ {
+ policyBuilder.WithOrigins([.. corsSettings.AllowedOrigins]);
+ policyBuilder.AllowCredentials();
+ }
+
policyBuilder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version"]);
- policyBuilder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept"]);
- policyBuilder.WithMethods(["POST", "GET", "PUT", "DELETE", "OPTIONS", "PATCH"]);
+ policyBuilder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization"]);
+ policyBuilder.WithMethods(["POST", "GET", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"]);
});
appBuilder.MapControllers();
diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt
index c33a62237d..9b088fad11 100644
--- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt
+++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt
@@ -1,6 +1,7 @@
GET / => ServiceControl.Infrastructure.WebApi.RootController:Urls()
GET /archive/groups/id/{groupId:required:minlength(1)} => ServiceControl.MessageFailures.Api.ArchiveMessagesController:GetGroup(String groupId, String status, String modified)
GET /configuration => ServiceControl.Infrastructure.WebApi.RootController:Config()
+GET /configuration => ServiceControl.Authentication.AuthenticationController:Configuration()
GET /configuration/remotes => ServiceControl.Infrastructure.WebApi.RootController:RemoteConfig(CancellationToken cancellationToken)
GET /connection => ServiceControl.Connection.ConnectionController:GetConnectionDetails()
GET /conversations/{conversationId:required:minlength(1)} => ServiceControl.CompositeViews.Messages.GetMessagesByConversationController:Messages(PagingInfo pagingInfo, SortInfo sortInfo, Boolean includeSystemMessages, String conversationId)
diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
index 246f3e5678..b702e8bfc2 100644
--- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
+++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
@@ -3,6 +3,39 @@
"LogLevel": "Information",
"LogPath": "C:\\Logs"
},
+ "OpenIdConnectSettings": {
+ "enabled": false,
+ "authority": null,
+ "audience": null,
+ "validateIssuer": true,
+ "validateAudience": true,
+ "validateLifetime": true,
+ "validateIssuerSigningKey": true,
+ "requireHttpsMetadata": true,
+ "servicePulseAuthority": null,
+ "servicePulseClientId": null,
+ "servicePulseApiScopes": null
+ },
+ "ForwardedHeadersSettings": {
+ "Enabled": true,
+ "TrustAllProxies": true,
+ "KnownProxiesRaw": [],
+ "KnownNetworks": []
+ },
+ "HttpsSettings": {
+ "Enabled": false,
+ "CertificatePath": null,
+ "CertificatePassword": null,
+ "RedirectHttpToHttps": false,
+ "HttpsPort": null,
+ "EnableHsts": false,
+ "HstsMaxAgeSeconds": 31536000,
+ "HstsIncludeSubDomains": false
+ },
+ "CorsSettings": {
+ "AllowAnyOrigin": true,
+ "AllowedOrigins": []
+ },
"NotificationsFilter": null,
"AllowMessageEditing": false,
"MessageFilter": null,
diff --git a/src/ServiceControl.UnitTests/Infrastructure/Settings/CorsSettingsTests.cs b/src/ServiceControl.UnitTests/Infrastructure/Settings/CorsSettingsTests.cs
new file mode 100644
index 0000000000..11e8bc50b4
--- /dev/null
+++ b/src/ServiceControl.UnitTests/Infrastructure/Settings/CorsSettingsTests.cs
@@ -0,0 +1,191 @@
+namespace ServiceControl.UnitTests.Infrastructure.Settings;
+
+using System;
+using NUnit.Framework;
+using ServiceControl.Configuration;
+using ServiceControl.Infrastructure;
+
+///
+/// Tests for which is shared infrastructure code
+/// used by all three instance types (ServiceControl, ServiceControl.Audit, ServiceControl.Monitoring).
+/// Each instance passes a different which only affects
+/// the environment variable prefix (e.g., SERVICECONTROL_, SERVICECONTROL_AUDIT_, MONITORING_).
+/// The parsing logic is identical, so testing with one namespace is sufficient.
+///
+[TestFixture]
+public class CorsSettingsTests
+{
+ static readonly SettingsRootNamespace TestNamespace = new("ServiceControl");
+
+ [TearDown]
+ public void TearDown()
+ {
+ // Clean up environment variables after each test
+ Environment.SetEnvironmentVariable("SERVICECONTROL_CORS_ALLOWANYORIGIN", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_CORS_ALLOWEDORIGINS", null);
+ }
+
+ [Test]
+ public void Should_default_to_allow_any_origin()
+ {
+ var settings = new CorsSettings(TestNamespace);
+
+ Assert.That(settings.AllowAnyOrigin, Is.True);
+ }
+
+ [Test]
+ public void Should_default_to_empty_allowed_origins()
+ {
+ var settings = new CorsSettings(TestNamespace);
+
+ Assert.That(settings.AllowedOrigins, Is.Empty);
+ }
+
+ [Test]
+ public void Should_parse_single_allowed_origin()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_CORS_ALLOWEDORIGINS", "https://example.com");
+
+ var settings = new CorsSettings(TestNamespace);
+
+ Assert.That(settings.AllowedOrigins, Has.Count.EqualTo(1));
+ Assert.That(settings.AllowedOrigins, Does.Contain("https://example.com"));
+ }
+
+ [Test]
+ public void Should_parse_multiple_comma_separated_origins()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_CORS_ALLOWEDORIGINS", "https://example.com,https://app.example.com,http://localhost:3000");
+
+ var settings = new CorsSettings(TestNamespace);
+
+ Assert.That(settings.AllowedOrigins, Has.Count.EqualTo(3));
+ Assert.That(settings.AllowedOrigins, Does.Contain("https://example.com"));
+ Assert.That(settings.AllowedOrigins, Does.Contain("https://app.example.com"));
+ Assert.That(settings.AllowedOrigins, Does.Contain("http://localhost:3000"));
+ }
+
+ [Test]
+ public void Should_parse_semicolon_separated_origins()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_CORS_ALLOWEDORIGINS", "https://example.com;https://other.com");
+
+ var settings = new CorsSettings(TestNamespace);
+
+ Assert.That(settings.AllowedOrigins, Has.Count.EqualTo(2));
+ Assert.That(settings.AllowedOrigins, Does.Contain("https://example.com"));
+ Assert.That(settings.AllowedOrigins, Does.Contain("https://other.com"));
+ }
+
+ [Test]
+ public void Should_trim_whitespace_from_origins()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_CORS_ALLOWEDORIGINS", " https://example.com , https://other.com ");
+
+ var settings = new CorsSettings(TestNamespace);
+
+ Assert.That(settings.AllowedOrigins, Has.Count.EqualTo(2));
+ Assert.That(settings.AllowedOrigins, Does.Contain("https://example.com"));
+ Assert.That(settings.AllowedOrigins, Does.Contain("https://other.com"));
+ }
+
+ [Test]
+ public void Should_normalize_origin_to_scheme_and_authority()
+ {
+ // Origin with path should be normalized to just scheme://authority
+ Environment.SetEnvironmentVariable("SERVICECONTROL_CORS_ALLOWEDORIGINS", "https://example.com/some/path");
+
+ var settings = new CorsSettings(TestNamespace);
+
+ Assert.That(settings.AllowedOrigins, Has.Count.EqualTo(1));
+ Assert.That(settings.AllowedOrigins, Does.Contain("https://example.com"));
+ }
+
+ [Test]
+ public void Should_preserve_port_in_origin()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_CORS_ALLOWEDORIGINS", "http://localhost:8080");
+
+ var settings = new CorsSettings(TestNamespace);
+
+ Assert.That(settings.AllowedOrigins, Has.Count.EqualTo(1));
+ Assert.That(settings.AllowedOrigins, Does.Contain("http://localhost:8080"));
+ }
+
+ [Test]
+ public void Should_ignore_invalid_origins()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_CORS_ALLOWEDORIGINS", "https://example.com,not-a-url,https://other.com");
+
+ var settings = new CorsSettings(TestNamespace);
+
+ Assert.That(settings.AllowedOrigins, Has.Count.EqualTo(2));
+ Assert.That(settings.AllowedOrigins, Does.Contain("https://example.com"));
+ Assert.That(settings.AllowedOrigins, Does.Contain("https://other.com"));
+ Assert.That(settings.AllowedOrigins, Does.Not.Contain("not-a-url"));
+ }
+
+ [Test]
+ public void Should_disable_allow_any_origin_when_specific_origins_configured()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_CORS_ALLOWEDORIGINS", "https://example.com");
+
+ var settings = new CorsSettings(TestNamespace);
+
+ Assert.That(settings.AllowAnyOrigin, Is.False);
+ }
+
+ [Test]
+ public void Should_respect_explicit_allow_any_origin_false()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_CORS_ALLOWANYORIGIN", "false");
+
+ var settings = new CorsSettings(TestNamespace);
+
+ Assert.That(settings.AllowAnyOrigin, Is.False);
+ }
+
+ [Test]
+ public void Should_keep_allow_any_origin_true_when_explicitly_set()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_CORS_ALLOWANYORIGIN", "true");
+
+ var settings = new CorsSettings(TestNamespace);
+
+ Assert.That(settings.AllowAnyOrigin, Is.True);
+ }
+
+ [Test]
+ public void Should_handle_empty_origins_string()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_CORS_ALLOWEDORIGINS", "");
+
+ var settings = new CorsSettings(TestNamespace);
+
+ Assert.That(settings.AllowedOrigins, Is.Empty);
+ Assert.That(settings.AllowAnyOrigin, Is.True);
+ }
+
+ [Test]
+ public void Should_handle_whitespace_only_origins_string()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_CORS_ALLOWEDORIGINS", " ");
+
+ var settings = new CorsSettings(TestNamespace);
+
+ Assert.That(settings.AllowedOrigins, Is.Empty);
+ Assert.That(settings.AllowAnyOrigin, Is.True);
+ }
+
+ [Test]
+ public void Should_handle_only_invalid_origins()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_CORS_ALLOWEDORIGINS", "not-a-url,also-not-valid");
+
+ var settings = new CorsSettings(TestNamespace);
+
+ // All origins invalid, so list is empty, but AllowAnyOrigin stays true (no valid origins to override)
+ Assert.That(settings.AllowedOrigins, Is.Empty);
+ Assert.That(settings.AllowAnyOrigin, Is.True);
+ }
+}
diff --git a/src/ServiceControl.UnitTests/Infrastructure/Settings/ForwardedHeadersSettingsTests.cs b/src/ServiceControl.UnitTests/Infrastructure/Settings/ForwardedHeadersSettingsTests.cs
new file mode 100644
index 0000000000..99a1ce76ef
--- /dev/null
+++ b/src/ServiceControl.UnitTests/Infrastructure/Settings/ForwardedHeadersSettingsTests.cs
@@ -0,0 +1,166 @@
+namespace ServiceControl.UnitTests.Infrastructure.Settings;
+
+using System;
+using System.Linq;
+using NUnit.Framework;
+using ServiceControl.Configuration;
+using ServiceControl.Infrastructure;
+
+///
+/// Tests for which is shared infrastructure code
+/// used by all three instance types (ServiceControl, ServiceControl.Audit, ServiceControl.Monitoring).
+/// Each instance passes a different which only affects
+/// the environment variable prefix (e.g., SERVICECONTROL_, SERVICECONTROL_AUDIT_, MONITORING_).
+/// The parsing logic is identical, so testing with one namespace is sufficient.
+///
+[TestFixture]
+public class ForwardedHeadersSettingsTests
+{
+ static readonly SettingsRootNamespace TestNamespace = new("ServiceControl");
+
+ [TearDown]
+ public void TearDown()
+ {
+ // Clean up environment variables after each test
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_ENABLED", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS", null);
+ }
+
+ [Test]
+ public void Should_parse_known_proxies_from_comma_separated_list()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "127.0.0.1,10.0.0.5,192.168.1.1");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.KnownProxiesRaw, Has.Count.EqualTo(3));
+ Assert.That(settings.KnownProxiesRaw, Does.Contain("127.0.0.1"));
+ Assert.That(settings.KnownProxiesRaw, Does.Contain("10.0.0.5"));
+ Assert.That(settings.KnownProxiesRaw, Does.Contain("192.168.1.1"));
+ }
+
+ [Test]
+ public void Should_parse_known_proxies_to_ip_addresses()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "127.0.0.1,10.0.0.5");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+ var ipAddresses = settings.KnownProxies.ToList();
+
+ Assert.That(ipAddresses, Has.Count.EqualTo(2));
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(ipAddresses[0].ToString(), Is.EqualTo("127.0.0.1"));
+ Assert.That(ipAddresses[1].ToString(), Is.EqualTo("10.0.0.5"));
+ }
+ }
+
+ [Test]
+ public void Should_ignore_invalid_ip_addresses()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "127.0.0.1,not-an-ip,10.0.0.5");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.KnownProxiesRaw, Has.Count.EqualTo(2));
+ Assert.That(settings.KnownProxiesRaw, Does.Contain("127.0.0.1"));
+ Assert.That(settings.KnownProxiesRaw, Does.Contain("10.0.0.5"));
+ Assert.That(settings.KnownProxiesRaw, Does.Not.Contain("not-an-ip"));
+ }
+
+ [Test]
+ public void Should_parse_known_networks_from_comma_separated_cidr()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS", "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.KnownNetworks, Has.Count.EqualTo(3));
+ Assert.That(settings.KnownNetworks, Does.Contain("10.0.0.0/8"));
+ Assert.That(settings.KnownNetworks, Does.Contain("172.16.0.0/12"));
+ Assert.That(settings.KnownNetworks, Does.Contain("192.168.0.0/16"));
+ }
+
+ [Test]
+ public void Should_ignore_invalid_network_cidr()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS", "10.0.0.0/8,invalid-network,172.16.0.0/12");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.KnownNetworks, Has.Count.EqualTo(2));
+ Assert.That(settings.KnownNetworks, Does.Contain("10.0.0.0/8"));
+ Assert.That(settings.KnownNetworks, Does.Contain("172.16.0.0/12"));
+ Assert.That(settings.KnownNetworks, Does.Not.Contain("invalid-network"));
+ }
+
+ [Test]
+ public void Should_disable_trust_all_proxies_when_known_proxies_configured()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "127.0.0.1");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.TrustAllProxies, Is.False);
+ }
+
+ [Test]
+ public void Should_disable_trust_all_proxies_when_known_networks_configured()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS", "10.0.0.0/8");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.TrustAllProxies, Is.False);
+ }
+
+ [Test]
+ public void Should_default_to_enabled()
+ {
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.Enabled, Is.True);
+ }
+
+ [Test]
+ public void Should_default_to_trust_all_proxies()
+ {
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.TrustAllProxies, Is.True);
+ }
+
+ [Test]
+ public void Should_respect_explicit_disabled_setting()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_ENABLED", "false");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.Enabled, Is.False);
+ }
+
+ [Test]
+ public void Should_handle_semicolon_separator_in_proxies()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "127.0.0.1;10.0.0.5");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.KnownProxiesRaw, Has.Count.EqualTo(2));
+ }
+
+ [Test]
+ public void Should_trim_whitespace_from_proxy_entries()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", " 127.0.0.1 , 10.0.0.5 ");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.KnownProxiesRaw, Has.Count.EqualTo(2));
+ Assert.That(settings.KnownProxiesRaw, Does.Contain("127.0.0.1"));
+ Assert.That(settings.KnownProxiesRaw, Does.Contain("10.0.0.5"));
+ }
+}
diff --git a/src/ServiceControl.UnitTests/Infrastructure/Settings/HttpsSettingsTests.cs b/src/ServiceControl.UnitTests/Infrastructure/Settings/HttpsSettingsTests.cs
new file mode 100644
index 0000000000..14e6915c6b
--- /dev/null
+++ b/src/ServiceControl.UnitTests/Infrastructure/Settings/HttpsSettingsTests.cs
@@ -0,0 +1,248 @@
+namespace ServiceControl.UnitTests.Infrastructure.Settings;
+
+using System;
+using System.IO;
+using NUnit.Framework;
+using ServiceControl.Configuration;
+using ServiceControl.Infrastructure;
+
+///
+/// Tests for which is shared infrastructure code
+/// used by all three instance types (ServiceControl, ServiceControl.Audit, ServiceControl.Monitoring).
+/// Each instance passes a different which only affects
+/// the environment variable prefix (e.g., SERVICECONTROL_, SERVICECONTROL_AUDIT_, MONITORING_).
+/// The parsing logic is identical, so testing with one namespace is sufficient.
+///
+[TestFixture]
+public class HttpsSettingsTests
+{
+ static readonly SettingsRootNamespace TestNamespace = new("ServiceControl");
+
+ string tempCertPath;
+
+ [SetUp]
+ public void SetUp() =>
+ // Create a temporary file to simulate a certificate file
+ tempCertPath = Path.GetTempFileName();
+
+ [TearDown]
+ public void TearDown()
+ {
+ // Clean up environment variables after each test
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_ENABLED", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_CERTIFICATEPATH", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_PORT", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_ENABLEHSTS", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_HSTSINCLUDESUBDOMAINS", null);
+
+ // Clean up temp file
+ if (File.Exists(tempCertPath))
+ {
+ File.Delete(tempCertPath);
+ }
+ }
+
+ [Test]
+ public void Should_default_to_disabled()
+ {
+ var settings = new HttpsSettings(TestNamespace);
+
+ Assert.That(settings.Enabled, Is.False);
+ }
+
+ [Test]
+ public void Should_default_redirect_to_disabled()
+ {
+ var settings = new HttpsSettings(TestNamespace);
+
+ Assert.That(settings.RedirectHttpToHttps, Is.False);
+ }
+
+ [Test]
+ public void Should_default_hsts_to_disabled()
+ {
+ var settings = new HttpsSettings(TestNamespace);
+
+ Assert.That(settings.EnableHsts, Is.False);
+ }
+
+ [Test]
+ public void Should_default_hsts_max_age_to_one_year()
+ {
+ var settings = new HttpsSettings(TestNamespace);
+
+ Assert.That(settings.HstsMaxAgeSeconds, Is.EqualTo(31536000));
+ }
+
+ [Test]
+ public void Should_default_hsts_include_subdomains_to_false()
+ {
+ var settings = new HttpsSettings(TestNamespace);
+
+ Assert.That(settings.HstsIncludeSubDomains, Is.False);
+ }
+
+ [Test]
+ public void Should_default_https_port_to_null()
+ {
+ var settings = new HttpsSettings(TestNamespace);
+
+ Assert.That(settings.HttpsPort, Is.Null);
+ }
+
+ [Test]
+ public void Should_enable_https_when_configured()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_ENABLED", "true");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_CERTIFICATEPATH", tempCertPath);
+
+ var settings = new HttpsSettings(TestNamespace);
+
+ Assert.That(settings.Enabled, Is.True);
+ }
+
+ [Test]
+ public void Should_read_certificate_path()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_ENABLED", "true");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_CERTIFICATEPATH", tempCertPath);
+
+ var settings = new HttpsSettings(TestNamespace);
+
+ Assert.That(settings.CertificatePath, Is.EqualTo(tempCertPath));
+ }
+
+ [Test]
+ public void Should_read_certificate_password()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_ENABLED", "true");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_CERTIFICATEPATH", tempCertPath);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD", "my-secret-password");
+
+ var settings = new HttpsSettings(TestNamespace);
+
+ Assert.That(settings.CertificatePassword, Is.EqualTo("my-secret-password"));
+ }
+
+ [Test]
+ public void Should_throw_when_https_enabled_without_certificate_path()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_ENABLED", "true");
+
+ var ex = Assert.Throws(() => new HttpsSettings(TestNamespace));
+
+ Assert.That(ex.Message, Does.Contain("CertificatePath is required"));
+ }
+
+ [Test]
+ public void Should_throw_when_certificate_path_does_not_exist()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_ENABLED", "true");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_CERTIFICATEPATH", "/nonexistent/path/cert.pfx");
+
+ var ex = Assert.Throws(() => new HttpsSettings(TestNamespace));
+
+ Assert.That(ex.Message, Does.Contain("does not exist"));
+ }
+
+ [Test]
+ public void Should_enable_redirect_when_configured()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS", "true");
+
+ var settings = new HttpsSettings(TestNamespace);
+
+ Assert.That(settings.RedirectHttpToHttps, Is.True);
+ }
+
+ [Test]
+ public void Should_read_https_port()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_PORT", "8443");
+
+ var settings = new HttpsSettings(TestNamespace);
+
+ Assert.That(settings.HttpsPort, Is.EqualTo(8443));
+ }
+
+ [Test]
+ public void Should_enable_hsts_when_configured()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_ENABLEHSTS", "true");
+
+ var settings = new HttpsSettings(TestNamespace);
+
+ Assert.That(settings.EnableHsts, Is.True);
+ }
+
+ [Test]
+ public void Should_read_custom_hsts_max_age()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS", "86400");
+
+ var settings = new HttpsSettings(TestNamespace);
+
+ Assert.That(settings.HstsMaxAgeSeconds, Is.EqualTo(86400));
+ }
+
+ [Test]
+ public void Should_enable_hsts_include_subdomains_when_configured()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_HSTSINCLUDESUBDOMAINS", "true");
+
+ var settings = new HttpsSettings(TestNamespace);
+
+ Assert.That(settings.HstsIncludeSubDomains, Is.True);
+ }
+
+ [Test]
+ public void Should_not_validate_certificate_when_disabled()
+ {
+ // HTTPS disabled (default) should not require certificate
+ var settings = new HttpsSettings(TestNamespace);
+
+ Assert.That(settings.Enabled, Is.False);
+ Assert.That(settings.CertificatePath, Is.Null);
+ }
+
+ [Test]
+ public void Should_allow_redirect_settings_without_https_enabled()
+ {
+ // Redirect settings can be configured even when HTTPS is disabled
+ // (they will be ignored but should not throw)
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS", "true");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_PORT", "443");
+
+ var settings = new HttpsSettings(TestNamespace);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(settings.Enabled, Is.False);
+ Assert.That(settings.RedirectHttpToHttps, Is.True);
+ Assert.That(settings.HttpsPort, Is.EqualTo(443));
+ }
+ }
+
+ [Test]
+ public void Should_allow_hsts_settings_without_https_enabled()
+ {
+ // HSTS settings can be configured even when HTTPS is disabled
+ // (they will be ignored but should not throw)
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_ENABLEHSTS", "true");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS", "3600");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_HSTSINCLUDESUBDOMAINS", "true");
+
+ var settings = new HttpsSettings(TestNamespace);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(settings.Enabled, Is.False);
+ Assert.That(settings.EnableHsts, Is.True);
+ Assert.That(settings.HstsMaxAgeSeconds, Is.EqualTo(3600));
+ Assert.That(settings.HstsIncludeSubDomains, Is.True);
+ }
+ }
+}
diff --git a/src/ServiceControl.UnitTests/Infrastructure/Settings/OpenIdConnectSettingsTests.cs b/src/ServiceControl.UnitTests/Infrastructure/Settings/OpenIdConnectSettingsTests.cs
new file mode 100644
index 0000000000..b7429612a8
--- /dev/null
+++ b/src/ServiceControl.UnitTests/Infrastructure/Settings/OpenIdConnectSettingsTests.cs
@@ -0,0 +1,293 @@
+namespace ServiceControl.UnitTests.Infrastructure.Settings;
+
+using System;
+using NUnit.Framework;
+using ServiceControl.Configuration;
+using ServiceControl.Infrastructure;
+
+///
+/// Tests for which is shared infrastructure code
+/// used by all three instance types (ServiceControl, ServiceControl.Audit, ServiceControl.Monitoring).
+/// Each instance passes a different which only affects
+/// the environment variable prefix (e.g., SERVICECONTROL_, SERVICECONTROL_AUDIT_, MONITORING_).
+/// The parsing logic is identical, so testing with one namespace is sufficient.
+///
+[TestFixture]
+public class OpenIdConnectSettingsTests
+{
+ static readonly SettingsRootNamespace TestNamespace = new("ServiceControl");
+
+ [TearDown]
+ public void TearDown()
+ {
+ // Clean up environment variables after each test
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ENABLED", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUTHORITY", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUDIENCE", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_VALIDATELIFETIME", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_VALIDATEISSUERSIGNINGKEY", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY", null);
+ }
+
+ [Test]
+ public void Should_default_to_disabled()
+ {
+ var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false);
+
+ Assert.That(settings.Enabled, Is.False);
+ }
+
+ [Test]
+ public void Should_default_validation_flags_to_true()
+ {
+ var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(settings.ValidateIssuer, Is.True);
+ Assert.That(settings.ValidateAudience, Is.True);
+ Assert.That(settings.ValidateLifetime, Is.True);
+ Assert.That(settings.ValidateIssuerSigningKey, Is.True);
+ Assert.That(settings.RequireHttpsMetadata, Is.True);
+ }
+ }
+
+ [Test]
+ public void Should_read_authority()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUTHORITY", "https://login.example.com");
+
+ var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false);
+
+ Assert.That(settings.Authority, Is.EqualTo("https://login.example.com"));
+ }
+
+ [Test]
+ public void Should_read_audience()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUDIENCE", "my-api-audience");
+
+ var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false);
+
+ Assert.That(settings.Audience, Is.EqualTo("my-api-audience"));
+ }
+
+ [Test]
+ public void Should_read_validation_flags()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER", "false");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE", "false");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_VALIDATELIFETIME", "false");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_VALIDATEISSUERSIGNINGKEY", "false");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA", "false");
+
+ var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(settings.ValidateIssuer, Is.False);
+ Assert.That(settings.ValidateAudience, Is.False);
+ Assert.That(settings.ValidateLifetime, Is.False);
+ Assert.That(settings.ValidateIssuerSigningKey, Is.False);
+ Assert.That(settings.RequireHttpsMetadata, Is.False);
+ }
+ }
+
+ [Test]
+ public void Should_read_service_pulse_settings_when_required()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID", "my-client-id");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", "api://my-api/.default");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY", "https://pulse-auth.example.com");
+
+ var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false, requireServicePulseSettings: true);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(settings.ServicePulseClientId, Is.EqualTo("my-client-id"));
+ Assert.That(settings.ServicePulseApiScopes, Is.EqualTo("api://my-api/.default"));
+ Assert.That(settings.ServicePulseAuthority, Is.EqualTo("https://pulse-auth.example.com"));
+ }
+ }
+
+ [Test]
+ public void Should_not_read_service_pulse_settings_when_not_required()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID", "my-client-id");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", "api://my-api/.default");
+
+ var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false, requireServicePulseSettings: false);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(settings.ServicePulseClientId, Is.Null);
+ Assert.That(settings.ServicePulseApiScopes, Is.Null);
+ }
+ }
+
+ [Test]
+ public void Should_throw_when_enabled_without_authority()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ENABLED", "true");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUDIENCE", "my-audience");
+
+ var ex = Assert.Throws(() => new OpenIdConnectSettings(TestNamespace, validateConfiguration: true, requireServicePulseSettings: false));
+
+ Assert.That(ex.Message, Does.Contain("Authority is required"));
+ }
+
+ [Test]
+ public void Should_throw_when_enabled_without_audience()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ENABLED", "true");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUTHORITY", "https://login.example.com");
+
+ var ex = Assert.Throws(() => new OpenIdConnectSettings(TestNamespace, validateConfiguration: true, requireServicePulseSettings: false));
+
+ Assert.That(ex.Message, Does.Contain("Audience is required"));
+ }
+
+ [Test]
+ public void Should_throw_when_authority_is_not_valid_uri()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ENABLED", "true");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUTHORITY", "not-a-valid-uri");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUDIENCE", "my-audience");
+
+ var ex = Assert.Throws(() => new OpenIdConnectSettings(TestNamespace, validateConfiguration: true, requireServicePulseSettings: false));
+
+ Assert.That(ex.Message, Does.Contain("must be a valid absolute URI"));
+ }
+
+ [Test]
+ public void Should_throw_when_authority_is_http_and_https_required()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ENABLED", "true");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUTHORITY", "http://login.example.com");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUDIENCE", "my-audience");
+
+ var ex = Assert.Throws(() => new OpenIdConnectSettings(TestNamespace, validateConfiguration: true, requireServicePulseSettings: false));
+
+ Assert.That(ex.Message, Does.Contain("must use HTTPS"));
+ }
+
+ [Test]
+ public void Should_allow_http_authority_when_https_not_required()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ENABLED", "true");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUTHORITY", "http://localhost:5000");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUDIENCE", "my-audience");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA", "false");
+
+ var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: true, requireServicePulseSettings: false);
+
+ Assert.That(settings.Authority, Is.EqualTo("http://localhost:5000"));
+ }
+
+ [Test]
+ public void Should_throw_when_service_pulse_client_id_missing()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ENABLED", "true");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUTHORITY", "https://login.example.com");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUDIENCE", "my-audience");
+
+ var ex = Assert.Throws(() => new OpenIdConnectSettings(TestNamespace, validateConfiguration: true, requireServicePulseSettings: true));
+
+ Assert.That(ex.Message, Does.Contain("ServicePulse.ClientId is required"));
+ }
+
+ [Test]
+ public void Should_throw_when_service_pulse_api_scopes_missing()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ENABLED", "true");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUTHORITY", "https://login.example.com");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUDIENCE", "my-audience");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID", "my-client-id");
+
+ var ex = Assert.Throws(() => new OpenIdConnectSettings(TestNamespace, validateConfiguration: true, requireServicePulseSettings: true));
+
+ Assert.That(ex.Message, Does.Contain("ServicePulse.ApiScopes is required"));
+ }
+
+ [Test]
+ public void Should_throw_when_service_pulse_authority_is_invalid()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ENABLED", "true");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUTHORITY", "https://login.example.com");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUDIENCE", "my-audience");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID", "my-client-id");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", "api://my-api/.default");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY", "not-a-valid-uri");
+
+ var ex = Assert.Throws(() => new OpenIdConnectSettings(TestNamespace, validateConfiguration: true, requireServicePulseSettings: true));
+
+ Assert.That(ex.Message, Does.Contain("ServicePulse.Authority must be a valid absolute URI"));
+ }
+
+ [Test]
+ public void Should_succeed_with_valid_full_configuration()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ENABLED", "true");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUTHORITY", "https://login.example.com");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUDIENCE", "my-audience");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID", "my-client-id");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", "api://my-api/.default");
+
+ var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: true, requireServicePulseSettings: true);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(settings.Enabled, Is.True);
+ Assert.That(settings.Authority, Is.EqualTo("https://login.example.com"));
+ Assert.That(settings.Audience, Is.EqualTo("my-audience"));
+ Assert.That(settings.ServicePulseClientId, Is.EqualTo("my-client-id"));
+ Assert.That(settings.ServicePulseApiScopes, Is.EqualTo("api://my-api/.default"));
+ }
+ }
+
+ [Test]
+ public void Should_succeed_without_service_pulse_settings_when_not_required()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ENABLED", "true");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUTHORITY", "https://login.example.com");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUDIENCE", "my-audience");
+
+ var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: true, requireServicePulseSettings: false);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(settings.Enabled, Is.True);
+ Assert.That(settings.Authority, Is.EqualTo("https://login.example.com"));
+ Assert.That(settings.Audience, Is.EqualTo("my-audience"));
+ }
+ }
+
+ [Test]
+ public void Should_not_validate_when_disabled()
+ {
+ // Even without required settings, validation should pass when auth is disabled
+ var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: true, requireServicePulseSettings: true);
+
+ Assert.That(settings.Enabled, Is.False);
+ }
+
+ [Test]
+ public void Should_allow_optional_service_pulse_authority()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ENABLED", "true");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUTHORITY", "https://login.example.com");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUDIENCE", "my-audience");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID", "my-client-id");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", "api://my-api/.default");
+ Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY", "https://pulse-auth.example.com");
+
+ var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: true, requireServicePulseSettings: true);
+
+ Assert.That(settings.ServicePulseAuthority, Is.EqualTo("https://pulse-auth.example.com"));
+ }
+}
diff --git a/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs b/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs
index 22eee8c632..4c327da5c8 100644
--- a/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs
+++ b/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs
@@ -6,6 +6,7 @@
using System.Net.Http;
using System.Threading.Tasks;
using CompositeViews.Messages;
+ using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
@@ -17,7 +18,7 @@ abstract class MessageView_ScatterGatherTest
[SetUp]
public void SetUp()
{
- var api = new TestApi(null, null, null, NullLogger.Instance);
+ var api = new TestApi(null, null, null, null, NullLogger.Instance);
Results = api.AggregateResults(new ScatterGatherApiMessageViewContext(new PagingInfo(), new SortInfo()), GetData());
}
@@ -68,8 +69,8 @@ protected IEnumerable RemoteData()
class TestApi : ScatterGatherApiMessageView
+
+
+
+
diff --git a/src/ServiceControl/WebApplicationExtensions.cs b/src/ServiceControl/WebApplicationExtensions.cs
index dfa7511613..685bc7dc16 100644
--- a/src/ServiceControl/WebApplicationExtensions.cs
+++ b/src/ServiceControl/WebApplicationExtensions.cs
@@ -3,13 +3,16 @@ namespace ServiceControl;
using Infrastructure.SignalR;
using Infrastructure.WebApi;
using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.HttpOverrides;
+using ServiceControl.Hosting.ForwardedHeaders;
+using ServiceControl.Hosting.Https;
+using ServiceControl.Infrastructure;
public static class WebApplicationExtensions
{
- public static void UseServiceControl(this WebApplication app)
+ public static void UseServiceControl(this WebApplication app, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings)
{
- app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All });
+ app.UseServiceControlForwardedHeaders(forwardedHeadersSettings);
+ app.UseServiceControlHttps(httpsSettings);
app.UseResponseCompression();
app.UseMiddleware();
app.UseHttpLogging();