diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 02e899fe0a..cd5a119de3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,7 +23,7 @@ jobs:
- name: Setup .NET SDK
uses: actions/setup-dotnet@v5.1.0
with:
- dotnet-version: 7.0.x
+ dotnet-version: 8.0.x
- name: Set up Node.js
uses: actions/setup-node@v6.1.0
with:
diff --git a/.gitignore b/.gitignore
index 5bf25ccac6..3a703b0f97 100644
--- a/.gitignore
+++ b/.gitignore
@@ -101,3 +101,10 @@ src/scaffolding.config
# Visual Studio Code
.vscode
+
+# AI
+.claude
+CLAUDE.md
+
+# local testing
+.local
\ No newline at end of file
diff --git a/docs/authentication-testing.md b/docs/authentication-testing.md
new file mode 100644
index 0000000000..b02c42bbf2
--- /dev/null
+++ b/docs/authentication-testing.md
@@ -0,0 +1,986 @@
+# Local Testing Authentication
+
+This guide provides scenario-based tests for ServicePulse's OIDC authentication. Use this to verify authentication behavior during local development.
+
+For additional details on authentication in ServicePulse, see the [ServicePulse Security](https://docs.particular.net/servicepulse/security/configuration/authentication) documentation.
+
+## Prerequisites
+
+- ServicePulse built locally (see [main README for instructions](../README.md#setting-up-the-project-for-development))
+- ServiceControl instance running (provides authentication configuration) - See the [hosting guide in ServiceControl docs](https://docs.particular.net/servicecontrol/security/hosting-guide) for more info.
+- (Optional) An OIDC identity provider for testing authenticated scenarios
+
+### Building the Frontend
+
+```cmd
+cd src\Frontend
+npm install
+npm run build
+```
+
+## Host Options
+
+ServicePulse has two hosting options for local testing:
+
+### .NET 8 Host
+
+Uses ASP.NET Core with built-in YARP reverse proxy. Configuration via environment variables.
+
+```cmd
+cd src\ServicePulse
+dotnet run
+```
+
+Default URL: `http://localhost:5291`
+
+### .NET Framework Host
+
+Uses OWIN self-hosting. Configuration via command-line arguments.
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url="http://localhost:8081"
+```
+
+Default URL: `http://localhost:8081`
+
+> [!NOTE]
+> The .NET Framework host does not have a built-in reverse proxy. The frontend connects directly to ServiceControl, which requires CORS configuration in ServiceControl.
+> For HTTPS with the .NET Framework host, you must bind the SSL certificate at the OS level using `netsh`. See [HTTPS Testing](https-testing.md) for details.
+
+## Test Scenarios
+
+### Scenario 1: Authentication Disabled (Default)
+
+Verify that ServicePulse works without authentication when ServiceControl has auth disabled.
+
+#### Option A: Using Mocks (No ServiceControl Required)
+
+Use the mock scenario for frontend development without running ServiceControl.
+
+**Start with mocks:**
+
+```cmd
+cd src\Frontend
+set VITE_MOCK_SCENARIO=auth-disabled
+npm run dev:mocks
+```
+
+**Test in browser:**
+
+1. Open `http://localhost:5173`
+2. ServicePulse should load directly without any login prompt
+3. The user profile menu should NOT appear in the header
+4. Console should show: `Loading mock scenario: auth-disabled`
+
+**Run automated tests:**
+
+```cmd
+cd src\Frontend
+npx vitest run ./test/specs/authentication
+```
+
+#### Option B: Using Real ServiceControl
+
+**Prerequisites:**
+
+- ServiceControl running with authentication disabled (default)
+
+**Start ServicePulse (.NET 8 host):**
+
+```cmd
+cd src\ServicePulse
+dotnet run
+```
+
+**Test in browser:**
+
+1. Open `http://localhost:5291`
+2. ServicePulse should load directly without any login prompt
+3. The user profile menu should NOT appear in the header
+
+**Verify with curl:**
+
+```cmd
+curl http://localhost:5291/js/app.constants.js
+```
+
+**Expected:** The constants file loads successfully. Access to ServicePulse does not require authentication.
+
+#### Option C: Using .NET Framework Host
+
+**Prerequisites:**
+
+- ServiceControl running with authentication disabled (default)
+- ServicePulse.Host built
+
+**Start ServicePulse (.NET Framework host):**
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url="http://localhost:8081"
+```
+
+**Test in browser:**
+
+1. Open `http://localhost:8081`
+2. ServicePulse should load directly without any login prompt
+3. The user profile menu should NOT appear in the header
+
+**Verify with curl:**
+
+```cmd
+curl http://localhost:8081/js/app.constants.js
+```
+
+**Expected:** The constants file loads successfully. Access to ServicePulse does not require authentication.
+
+### Scenario 2: Verify Authentication Configuration Endpoint
+
+Test that ServiceControl returns the correct authentication configuration for ServicePulse.
+
+#### Option A: Using Mocks (No ServiceControl Required)
+
+Use the mock scenario to verify the auth configuration endpoint returns the expected response shape.
+
+> [!WARNING]
+> The app will attempt to redirect to the identity provider, which will fail without a real IdP - this is expected behavior.
+
+**Start with mocks:**
+
+```cmd
+cd src\Frontend
+set VITE_MOCK_SCENARIO=auth-enabled
+npm run dev:mocks
+```
+
+**Test in browser:**
+
+1. Open `http://localhost:5173`
+2. Open Developer Tools > Network tab
+3. Look for request to `/api/authentication/configuration`
+4. Verify response matches the expected mock values below
+
+**Expected mock response:**
+
+```json
+{
+ "enabled": true,
+ "client_id": "servicepulse-test",
+ "authority": "https://login.microsoftonline.com/test-tenant-id/v2.0",
+ "api_scopes": "[\"api://servicecontrol/access_as_user\"]",
+ "audience": "api://servicecontrol"
+}
+```
+
+**Run automated tests:**
+
+```cmd
+cd src\Frontend
+npx vitest run ./test/specs/authentication/auth-enabled
+```
+
+#### Option B: Using Real ServiceControl (.NET 8 Host)
+
+**Configure and start ServiceControl:**
+
+See ServiceControl docs for correct setup
+
+Set ServiceControl and ServicePulse with HTTPS.
+
+```cmd
+set SERVICEPULSE_HTTPS_ENABLED=true
+set SERVICEPULSE_HTTPS_CERTIFICATEPATH=C:\Users\warwi\source\repos\Particular\ServicePulse\.local\certs\localhost.pfx
+set SERVICEPULSE_HTTPS_CERTIFICATEPASSWORD=changeit
+set SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICEPULSE_HTTPS_PORT=
+set SERVICEPULSE_HTTPS_ENABLEHSTS=
+
+cd src\ServicePulse
+dotnet run
+```
+
+**Test with curl:**
+
+```cmd
+curl --ssl-no-revoke https://localhost:33333/api/authentication/configuration | json
+```
+
+**Expected output:**
+
+```json
+{
+ "enabled": true,
+ "client_id": "{servicepulse-client-id}",
+ "authority": "https://login.microsoftonline.com/{tenant-id}/v2.0",
+ "audience": "api://servicecontrol",
+ "api_scopes": "[\"api://servicecontrol/access_as_user\"]"
+}
+```
+
+The configuration endpoint is accessible without authentication and returns all fields ServicePulse needs to initiate the OIDC flow.
+
+#### Option C: Using Real ServiceControl (.NET Framework Host)
+
+**Prerequisites:**
+
+- ServiceControl running with authentication enabled
+- SSL certificate bound at OS level via `netsh` (see [HTTPS Testing](https-testing.md))
+- ServicePulse.Host built (run `build.ps1` from repo root)
+
+See [HTTPS Testing](https-testing.md) for detailed certificate setup instructions.
+
+**Start ServicePulse (.NET Framework host with HTTPS):**
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url="https://localhost:9090" --httpsenabled=true
+```
+
+Ensure you update the ServiceControl and Monitoring URLs to HTTLS
+
+**Test with curl:**
+
+```cmd
+curl --ssl-no-revoke https://localhost:33333/api/authentication/configuration | json
+```
+
+**Expected output:**
+
+```json
+{
+ "enabled": true,
+ "client_id": "{servicepulse-client-id}",
+ "authority": "https://login.microsoftonline.com/{tenant-id}/v2.0",
+ "audience": "api://servicecontrol",
+ "api_scopes": "[\"api://servicecontrol/access_as_user\"]"
+}
+```
+
+> [!NOTE]
+> The .NET Framework host does not have a reverse proxy. Authentication configuration is fetched directly from ServiceControl, so ensure ServiceControl has CORS configured to allow requests from the ServicePulse origin.
+
+### Scenario 3: Authentication Enabled (Browser Flow)
+
+Verify the OIDC login flow when authentication is enabled.
+
+#### Using Mocks (No ServiceControl Required)
+
+Use the mock scenario to simulate an authenticated user state. This bypasses the actual OIDC redirect flow and injects a mock token directly.
+
+**Start with mocks:**
+
+```cmd
+cd src\Frontend
+set VITE_MOCK_SCENARIO=auth-authenticated
+npm run dev:mocks
+```
+
+**Test in browser:**
+
+1. Open `http://localhost:5173`
+2. Dashboard should load directly (no login redirect)
+3. User profile menu should appear in the header
+4. Check Developer Tools > Application > Session Storage for `auth_token`
+
+**Expected behavior:**
+
+- Console shows: `Existing user session found { name: 'Test User', email: 'test.user@example.com' }`
+- Console shows: `User authenticated successfully`
+- Dashboard loads without redirect
+
+**Check Session Storage (Developer Tools > Application > Session Storage):**
+
+Key: `oidc.user:https://login.microsoftonline.com/test-tenant-id/v2.0:servicepulse-test`
+
+```json
+{
+ "access_token": "mock-access-token-for-testing",
+ "token_type": "Bearer",
+ "profile": {
+ "name": "Test User",
+ "email": "test.user@example.com",
+ "sub": "user-123"
+ }
+}
+```
+
+**Run automated tests:**
+
+```cmd
+cd src\Frontend
+npx vitest run ./test/specs/authentication/auth-authenticated
+```
+
+#### Using Real ServiceControl and Identity Provider (.NET 8 Host)
+
+**Prerequisites:**
+
+- ServiceControl running with authentication enabled (see Scenario 2)
+- OIDC identity provider configured (Microsoft Entra ID, Okta, Auth0, etc.)
+
+**Start ServicePulse:**
+
+```cmd
+cd src\ServicePulse
+dotnet run
+```
+
+**Test in browser:**
+
+1. Open `https://localhost:5291`
+2. ServicePulse should show a loading screen while fetching auth config
+3. Browser should redirect to the identity provider login page
+4. Enter valid credentials (if not already authenticated in current session)
+5. After successful login, browser redirects back to ServicePulse
+6. ServicePulse dashboard should load
+7. User profile menu should appear in the header showing user name and email
+
+#### Using Real ServiceControl and Identity Provider (.NET Framework Host)
+
+**Prerequisites:**
+
+- ServiceControl running with authentication enabled (see Scenario 2)
+- OIDC identity provider configured (Microsoft Entra ID, Okta, Auth0, etc.)
+- SSL certificate bound at OS level via `netsh`
+- ServicePulse.Host built (run `build.ps1` from repo root)
+- Redirect URI registered in IdP: `https://localhost:8443/`
+
+**Start ServicePulse (.NET Framework host):**
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url="https://localhost:8443" --httpsenabled=true
+```
+
+**Test in browser:**
+
+1. Open `https://localhost:8443`
+2. ServicePulse should show a loading screen while fetching auth config
+3. Browser should redirect to the identity provider login page
+4. Enter valid credentials (if not already authenticated in current session)
+5. After successful login, browser redirects back to ServicePulse
+6. ServicePulse dashboard should load
+7. User profile menu should appear in the header showing user name and email
+
+> [!IMPORTANT]
+> Ensure the redirect URI `https://localhost:8443/` is registered in your identity provider's application configuration. The .NET Framework host uses a different port than the .NET 8 host.
+
+### Scenario 4: Token Included in API Requests
+
+Verify that authenticated requests include the Bearer token.
+
+**Run automated tests:**
+
+```cmd
+cd src\Frontend
+npx vitest run ./test/specs/authentication/auth-token-in-requests
+```
+
+**Manual test in browser:**
+
+Prerequisites: Completed Scenario 3 (logged in successfully)
+
+1. Open browser Developer Tools (F12)
+2. Go to the Network tab
+3. Navigate to different pages in ServicePulse (Dashboard, Failed Messages, etc.)
+4. Click on API requests to ServiceControl (e.g., `/api/endpoints`)
+5. Check the Request Headers
+
+**Expected:** Each API request includes:
+
+```text
+Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
+```
+
+**Test with curl (using a token):**
+
+If you have obtained a token (e.g., from browser Developer Tools > Application > Session Storage > `oidc.user:*`), you can test API requests directly:
+
+```cmd
+rem Set your token (copy from browser session storage)
+set TOKEN=eyJhbGciOiJSUzI1NiIs...
+
+rem Test against ServiceControl directly
+curl -v -H "Authorization: Bearer %TOKEN%" http://localhost:33333/api/endpoints
+
+rem Test through ServicePulse reverse proxy
+curl -v -H "Authorization: Bearer %TOKEN%" http://localhost:5291/api/endpoints
+```
+
+**Expected:** Both requests return `200 OK` with endpoint data (JSON array).
+
+### Scenario 5: Unauthenticated API Access Blocked
+
+Verify that API requests without a token are rejected when auth is enabled.
+
+**Run automated tests:**
+
+```cmd
+cd src\Frontend
+npx vitest run ./test/specs/authentication/auth-unauthenticated
+```
+
+**Manual test with curl (no token):**
+
+Prerequisites: ServiceControl running with authentication enabled
+
+```cmd
+curl -v http://localhost:33333/api/endpoints
+```
+
+**Expected output:**
+
+```text
+HTTP/1.1 401 Unauthorized
+```
+
+The request is rejected because no Bearer token was provided.
+
+### Scenario 6: Session Persistence
+
+Verify that the session persists within a browser tab.
+
+**Run automated tests:**
+
+```cmd
+cd src\Frontend
+npx vitest run ./test/specs/authentication/auth-session-persistence
+```
+
+**Manual test in browser:**
+
+Prerequisites: Completed Scenario 3 (logged in successfully)
+
+1. After logging in, note that ServicePulse is working
+2. Refresh the page (F5)
+3. ServicePulse should reload without requiring login again
+4. Navigate between different pages
+
+**Expected:** The session persists. User remains authenticated without re-login.
+
+### Scenario 7: Session Isolation Between Tabs
+
+Verify that sessions are isolated between browser tabs.
+
+**Run automated tests:**
+
+```cmd
+cd src\Frontend
+npx vitest run ./test/specs/authentication/auth-tab-isolation
+```
+
+The automated test verifies that the auth token is stored in `sessionStorage` (tab-specific) and NOT in `localStorage` (shared across tabs).
+
+**Manual test in browser:**
+
+Prerequisites: Completed Scenario 3 (logged in successfully in one tab)
+
+1. Open a new browser tab
+2. Navigate to `http://localhost:5291`
+3. The new tab should redirect to the identity provider for login
+
+**Expected:** Each tab requires its own login because tokens are stored in `sessionStorage` (tab-specific).
+
+> [!NOTE]
+> If your identity provider maintains a session (SSO), the login may complete automatically without prompting for credentials.
+
+### Scenario 8: Logout Flow
+
+Verify that logout clears the session and redirects to the logged-out page.
+
+**Run automated tests:**
+
+```cmd
+cd src\Frontend
+npx vitest run ./test/specs/authentication/auth-logout
+```
+
+**Manual test in browser:**
+
+Prerequisites: Completed Scenario 3 (logged in successfully)
+
+1. Click on the user profile menu in the header
+2. Click "Log out"
+3. Browser should redirect to the identity provider's logout page
+4. After IdP logout, browser redirects to `http://localhost:5291/#/logged-out`
+5. The logged-out page displays:
+ - "You have been signed out" message
+ - "Sign in again" button
+6. Click "Sign in again" to initiate a new login
+
+**Expected:** The session is cleared. User sees the logged-out confirmation page and can sign in again.
+
+**Test with curl (verify logged-out page is accessible without auth):**
+
+```cmd
+curl http://localhost:5291/#/logged-out
+```
+
+**Expected:** The logged-out page should be accessible without authentication (it's an anonymous route).
+
+### Scenario 9: Silent Token Renewal
+
+Verify that tokens are renewed automatically before expiration.
+
+**Run automated tests:**
+
+```cmd
+cd src\Frontend
+npx vitest run ./test/specs/authentication/auth-silent-renewal
+```
+
+The automated test verifies that the UserManager is initialized with silent renewal support (mocked `signinSilent` method).
+
+**Manual test in browser:**
+
+Prerequisites:
+
+- Completed Scenario 3 (logged in successfully)
+- Identity provider configured with short-lived access tokens (for testing)
+
+1. Open browser Developer Tools (F12)
+2. Go to the Network tab
+3. Filter by "silent-renew"
+4. Leave ServicePulse open and wait for the token to approach expiration
+5. Watch for a request to `silent-renew.html`
+
+**Expected:** Before the token expires, a silent renewal request is made via an iframe. The token is refreshed without user interaction.
+
+> [!NOTE]
+> Silent renewal timing depends on your identity provider's token lifetime configuration.
+
+### Scenario 10: Silent Renewal Failure
+
+Verify behavior when silent renewal fails (e.g., IdP session expired).
+
+**Run automated tests:**
+
+```cmd
+cd src\Frontend
+npx vitest run ./test/specs/authentication/auth-renewal-failure
+```
+
+The automated test uses a mock where `signinSilent` fails, verifying the app handles renewal failures gracefully.
+
+**Manual test in browser:**
+
+Prerequisites:
+
+- Completed Scenario 3 (logged in successfully)
+- Identity provider session expired or revoked
+
+1. Log in to ServicePulse
+2. In a separate tab, log out from your identity provider (or wait for IdP session to expire)
+3. Return to ServicePulse and wait for token renewal to be attempted
+4. Or refresh the page
+
+**Expected:** ServicePulse detects the authentication failure and redirects to the identity provider login page.
+
+### Scenario 11: Invalid Redirect URI
+
+Verify error handling when redirect URI is misconfigured.
+
+**Prerequisites:**
+
+- ServiceControl with authentication enabled
+- Redirect URI in identity provider does NOT match ServicePulse URL
+
+**Test in browser:**
+
+1. Open `http://localhost:5291`
+2. Browser redirects to identity provider
+3. After login, identity provider rejects the redirect
+
+**Expected:** Identity provider shows an error about invalid redirect URI. This indicates misconfiguration in the IdP application registration.
+
+**Automated test:**
+
+```bash
+cd src/Frontend
+npx vitest run test/specs/authentication/auth-invalid-redirect.spec.ts
+```
+
+> [!NOTE]
+> The automated test verifies the app's behavior when OAuth callbacks fail. The actual "invalid redirect URI" error is displayed by the identity provider itself, not the application.
+
+### Scenario 12: YARP Reverse Proxy Token Forwarding
+
+Verify that the internal YARP reverse proxy forwards Bearer tokens to ServiceControl.
+
+**Prerequisites:**
+
+- ServiceControl running with authentication enabled (see Scenario 2)
+- ServicePulse running with reverse proxy enabled (default)
+- Completed Scenario 3 (logged in successfully)
+
+**How YARP works:**
+
+When the reverse proxy is enabled (default), ServicePulse serves the frontend at `https://localhost:5291` and proxies API requests:
+
+- `/api/*` → forwarded to ServiceControl (e.g., `http://localhost:33333/`)
+- `/monitoring-api/*` → forwarded to Monitoring instance
+
+YARP automatically forwards the `Authorization` header to downstream services.
+
+**Test in browser:**
+
+1. Open browser Developer Tools (F12) > Network tab
+2. Navigate to the Dashboard or any page that loads data
+3. Find a request to `/api/endpoints` (or similar)
+4. Verify the request URL is `/api/endpoints` (relative, through YARP)
+5. Check the Request Headers include `Authorization: Bearer ...`
+6. Verify the response is `200 OK` with data (not `401 Unauthorized`)
+
+**Expected:** Requests through the YARP proxy include the Bearer token and ServiceControl accepts them.
+
+**Verify reverse proxy is enabled:**
+
+```cmd
+curl https://localhost:5291/js/app.constants.js
+```
+
+When reverse proxy is enabled, `service_control_url` should be `"/api/"` (relative path).
+
+**Automated test:**
+
+```bash
+cd src/Frontend
+npx vitest run test/specs/authentication/auth-yarp-proxy.spec.ts
+```
+
+### Scenario 13: Direct ServiceControl Access (Reverse Proxy Disabled)
+
+Verify authentication works when the reverse proxy is disabled and the frontend connects directly to ServiceControl.
+
+**Prerequisites:**
+
+- ServiceControl running with authentication enabled
+- OIDC identity provider configured
+
+**Start ServicePulse with reverse proxy disabled:**
+
+```cmd
+set ENABLE_REVERSE_PROXY=false
+cd src\ServicePulse
+dotnet run
+```
+
+**Verify configuration:**
+
+```cmd
+curl https://localhost:5291/js/app.constants.js
+```
+
+When reverse proxy is disabled, `service_control_url` should be the full ServiceControl URL (e.g., `"http://localhost:33333/api/"`).
+
+**Test in browser:**
+
+1. Open `https://localhost:5291`
+2. Complete the login flow
+3. Open Developer Tools > Network tab
+4. Navigate to pages that load data
+5. Verify requests go directly to ServiceControl URL (not `/api/`)
+6. Verify requests include `Authorization: Bearer ...` header
+7. Verify responses are successful
+
+**Expected:** Direct requests to ServiceControl include the Bearer token and are accepted.
+
+**Automated test:**
+
+```bash
+cd src/Frontend
+npx vitest run test/specs/authentication/auth-direct-access.spec.ts
+```
+
+### Scenario 14: Forwarded Headers with Authentication
+
+Verify that forwarded headers work correctly with authentication when behind a reverse proxy (e.g., NGINX).
+
+**Prerequisites:**
+
+- ServiceControl running with authentication enabled
+- External reverse proxy (NGINX) configured (see [Reverse Proxy Testing](nginx-testing.md))
+- ServicePulse configured to trust forwarded headers
+
+#### Using .NET 8 Host
+
+**Configure ServicePulse:**
+
+```cmd
+set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true
+set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=true
+cd src\ServicePulse
+dotnet run
+```
+
+**Test in browser:**
+
+1. Access ServicePulse through the external reverse proxy (e.g., `https://servicepulse.localhost/`)
+2. Complete the login flow
+3. Verify the OAuth redirect URI uses the correct external URL
+4. Verify ServicePulse loads successfully after login
+
+**Expected:** The forwarded headers (`X-Forwarded-Proto`, `X-Forwarded-Host`) are processed correctly, and OAuth redirects use the external URL.
+
+#### Using .NET Framework Host
+
+**Configure ServicePulse:**
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url="http://localhost:8081" --forwardedheadersenabled=true --forwardedheaderstrustallproxies=true
+```
+
+Or to restrict to specific proxy IPs:
+
+```cmd
+ServicePulse.Host.exe --url="http://localhost:8081" --forwardedheadersenabled=true --forwardedheaderstrustallproxies=false --forwardedheadersknownproxies="127.0.0.1,::1"
+```
+
+**Test in browser:**
+
+1. Access ServicePulse through the external reverse proxy (e.g., `https://servicepulse.localhost/`)
+2. Complete the login flow
+3. Verify the OAuth redirect URI uses the correct external URL
+4. Verify ServicePulse loads successfully after login
+
+**Expected:** The forwarded headers (`X-Forwarded-Proto`, `X-Forwarded-Host`) are processed correctly, and OAuth redirects use the external URL.
+
+> [!NOTE]
+> The .NET Framework host has forwarded headers enabled by default with `TrustAllProxies=true`. For production, consider specifying known proxies explicitly.
+
+### Scenario 15: Auth Configuration Endpoint Unavailable
+
+Verify graceful handling when ServiceControl is unavailable during authentication configuration fetch.
+
+**Prerequisites:**
+
+- ServicePulse running
+- ServiceControl NOT running
+
+**Test in browser:**
+
+1. Stop ServiceControl
+2. Open `https://localhost:5291`
+3. Observe the loading behavior
+
+**Expected:** ServicePulse should display an error message indicating it cannot connect to ServiceControl, rather than crashing or showing a blank screen.
+
+**Automated test:**
+
+```bash
+cd src/Frontend
+npx vitest run test/specs/authentication/auth-config-unavailable.spec.ts
+```
+
+> [!NOTE]
+> The automated tests verify the app handles auth config endpoint errors gracefully by falling back to "auth disabled" mode, allowing users to still access the dashboard.
+
+### Scenario 16: OAuth Callback Error Handling
+
+Verify that OAuth errors returned in the callback are handled gracefully.
+
+**Prerequisites:**
+
+- ServiceControl running with authentication enabled
+- OIDC identity provider configured
+
+**Test by simulating an error:**
+
+1. Manually navigate to a URL with an error parameter:
+
+ ```text
+ https://localhost:5291/?error=access_denied&error_description=User%20cancelled%20the%20login
+ ```
+
+2. Observe the behavior
+
+**Expected:** ServicePulse should display the error message to the user and allow them to retry authentication.
+
+**Automated test:**
+
+```bash
+cd src/Frontend
+npx vitest run test/specs/authentication/auth-callback-error.spec.ts
+```
+
+### Scenario 17: Monitoring Instance Authentication
+
+Verify that authentication tokens are forwarded to the Monitoring instance as well.
+
+**Prerequisites:**
+
+- ServiceControl running with authentication enabled
+- ServiceControl Monitoring instance running
+- Completed Scenario 3 (logged in successfully)
+
+**Test in browser:**
+
+1. Navigate to the Monitoring tab in ServicePulse
+2. Open Developer Tools > Network tab
+3. Find requests to `/monitoring-api/*`
+4. Verify requests include `Authorization: Bearer ...` header
+5. Verify Monitoring data loads successfully
+
+**Expected:** Monitoring API requests through YARP include the Bearer token and return data successfully.
+
+## Development Mode with Mocks
+
+For frontend development without a real identity provider, you can use MSW (Mock Service Worker) to mock the authentication endpoint.
+
+**Start with mocks:**
+
+```cmd
+cd src\Frontend
+npm run dev:mocks
+```
+
+**Mock authentication disabled:**
+
+The default mock configuration returns authentication as disabled. To test authenticated scenarios, you'll need to add mock handlers for the auth configuration endpoint.
+
+## Automated Testing
+
+Automated tests for authentication use **Vitest + MSW** (Mock Service Worker) to test auth flows without requiring a real identity provider.
+
+**Approach:**
+
+- Mock the `/api/authentication/configuration` endpoint to return auth-enabled config
+- Mock the `oidc-client-ts` UserManager to simulate authenticated users
+- Test UI behavior (profile menu appears, logout works, etc.)
+- Test that API requests include the Bearer token
+
+**Run automated auth tests:**
+
+```cmd
+cd src\Frontend
+npm run test:component
+```
+
+See `src/Frontend/test/preconditions/` for auth-related test setup factories.
+
+## Troubleshooting
+
+### "Authentication required" but no redirect
+
+**Possible causes:**
+
+1. ServiceControl auth configuration missing `authority` or `client_id`
+2. OIDC library failed to initialize
+
+**Solution:** Check browser console for JavaScript errors. Verify ServiceControl auth configuration is complete.
+
+### Redirect loop between ServicePulse and IdP
+
+**Possible causes:**
+
+1. Redirect URI mismatch (trailing slash difference)
+2. Token validation failing
+
+**Solution:** Ensure redirect URI in IdP exactly matches `http://localhost:5291/` (with or without trailing slash consistently).
+
+### "CORS error" in browser console
+
+**Possible causes:**
+
+1. Identity provider doesn't allow requests from `http://localhost:5291`
+2. ServiceControl doesn't have CORS configured for the ServicePulse origin
+
+**Solution:** Configure CORS in your identity provider to allow the ServicePulse origin.
+
+### Token not refreshing
+
+**Possible causes:**
+
+1. `offline_access` scope not granted
+2. Third-party cookies blocked by browser
+3. `silent-renew.html` not accessible
+
+**Solution:**
+
+1. Ensure `offline_access` is in the requested scopes
+2. Check browser settings for third-party cookie restrictions
+3. Verify `http://localhost:5291/silent-renew.html` returns successfully
+
+### API requests still failing after login
+
+**Possible causes:**
+
+1. Token audience doesn't match ServiceControl's expected audience
+2. Token scopes don't include required API scopes
+3. Token signature validation failing
+
+**Solution:** Verify identity provider configuration matches ServiceControl's expected values for audience and scopes.
+
+### 401 errors through YARP reverse proxy
+
+**Possible causes:**
+
+1. ServiceControl not configured to accept the token
+2. Token audience mismatch between ServicePulse and ServiceControl configuration
+3. CORS issues when ServiceControl and ServicePulse are on different origins
+
+**Solution:**
+
+1. Verify ServiceControl is configured with matching authentication settings
+2. Check that the `audience` in ServiceControl matches the token's audience claim
+3. When using reverse proxy (default), requests go to the same origin so CORS should not be an issue
+
+### Forwarded headers not working
+
+**Possible causes:**
+
+1. Forwarded headers middleware not enabled
+2. Request not coming from a trusted proxy
+3. Headers being stripped by intermediate proxy
+
+**Solution:**
+
+1. Ensure `SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true`
+2. Configure trusted proxies: `SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES` or `SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=true`
+3. Check that your reverse proxy is sending `X-Forwarded-Proto`, `X-Forwarded-Host`, and `X-Forwarded-For` headers
+
+### OAuth redirect uses wrong URL behind proxy
+
+**Possible causes:**
+
+1. Forwarded headers not being processed
+2. Identity provider redirect URI doesn't match the external URL
+
+**Solution:**
+
+1. Enable and configure forwarded headers (see above)
+2. Update the redirect URI in your identity provider to match the external URL (e.g., `https://servicepulse.example.com/`)
+
+## Browser Developer Tools Tips
+
+### Viewing Token Contents
+
+1. Open Developer Tools > Application tab
+2. Expand Session Storage
+3. Find the `oidc.user:` key
+4. The stored object contains the access token and user info
+
+### Decoding JWT Tokens
+
+Copy the access token and paste it into [jwt.io](https://jwt.io) to view:
+
+- Header (algorithm, key ID)
+- Payload (claims, scopes, expiration)
+- Signature
+
+### Monitoring Auth Requests
+
+1. Open Developer Tools > Network tab
+2. Filter by domain of your identity provider
+3. Watch for:
+ - `/authorize` - Initial login redirect
+ - `/token` - Token exchange
+ - `/userinfo` - User profile fetch (if enabled)
+
+## See Also
+
+- [Authentication](https://docs.particular.net/servicepulse/security/configuration/authentication) - Authentication configuration reference
+- [HTTPS Configuration](https://docs.particular.net/servicepulse/security/configuration/tls#configuration) - Secure token transmission with HTTPS
+- [Reverse Proxy Testing](nginx-testing.md) - Testing with NGINX reverse proxy
diff --git a/docs/forwarded-headers-testing.md b/docs/forwarded-headers-testing.md
new file mode 100644
index 0000000000..b593c046b3
--- /dev/null
+++ b/docs/forwarded-headers-testing.md
@@ -0,0 +1,849 @@
+# Local Testing Forward Headers (Without NGINX)
+
+This guide explains how to test forward headers configuration for ServicePulse without using NGINX or Docker. This approach uses curl to manually send `X-Forwarded-*` headers directly to the application.
+
+## Prerequisites
+
+- ServicePulse built locally (see [main README for instructions](../README.md#setting-up-the-project-for-development))
+- 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`
+
+### Building ServicePulse.Host (.NET Framework)
+
+If testing the .NET Framework version, build ServicePulse.Host first:
+
+```cmd
+REM Build the frontend
+cd src\Frontend
+npm install
+npm run build
+
+REM Copy the frontend build to the host
+cd ..\..
+xcopy /E /I /Y src\Frontend\dist src\ServicePulse.Host\app
+cd src\ServicePulse.Host
+dotnet build
+```
+
+## Application Reference
+
+| Application | Project Directory | Default Port | Configuration |
+|------------------------------------|-------------------------|--------------|---------------------------------------------------|
+| ServicePulse (.NET 8) | `src\ServicePulse` | 5291 | Environment variables with `SERVICEPULSE_` prefix |
+| ServicePulse.Host (.NET Framework) | `src\ServicePulse.Host` | 8081 | Command-line arguments with `--` prefix |
+
+### Configuration Settings
+
+See [Forward Header Configuration](https://docs.particular.net/servicepulse/security/configuration/forward-headers)
+
+## 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`
+
+## Test Scenarios
+
+Each scenario shows configuration for both platforms:
+
+- **ServicePulse (.NET 8)**: Uses environment variables, run from `src\ServicePulse`
+- **ServicePulse.Host (.NET Framework)**: Uses command-line arguments, run from `src\ServicePulse.Host\bin\Debug\net48`
+
+> [!IMPORTANT]
+> All commands assume you start in the repository root folder. Each scenario includes `cd` commands to navigate to the correct directory.
+> For .NET 8, set environment variables in the same terminal where you run `dotnet run`. Environment variables are scoped to the terminal session.
+
+---
+
+### Default Configuration (No Settings)
+
+These scenarios use default configuration with no forwarded headers settings specified.
+
+**Start the application:**
+
+**.NET 8:**
+
+```cmd
+set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=
+set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+cd src\ServicePulse
+dotnet run
+```
+
+**.NET Framework:**
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url=http://localhost:8081
+```
+
+#### Scenario 1: Direct Access (No Headers)
+
+Test a direct request without any forwarded headers, simulating access without a reverse proxy.
+
+**Test with curl (no forwarded headers):**
+
+```cmd
+curl http://localhost:5291/debug/request-info | json
+curl http://localhost:8081/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "http",
+ "host": "localhost:5291", // or localhost:8081 for .NET Framework
+ "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 2: Default Behavior (With Headers)
+
+Test the default behavior when no forwarded headers configuration is set, but headers are sent.
+
+**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:5291/debug/request-info | json
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:8081/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`.
+
+---
+
+### Trust All Proxies (Explicit)
+
+These scenarios explicitly enable trust for all proxies. This is the same as default behavior but with explicit configuration.
+
+**Start the application:**
+
+**.NET 8:**
+
+```cmd
+set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true
+set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=true
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+cd src\ServicePulse
+dotnet run
+```
+
+**.NET Framework:**
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheadersenabled=true --forwardedheaderstrustallproxies=true
+```
+
+#### Scenario 3: Basic Headers Processing
+
+**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:5291/debug/request-info | json
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:8081/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 4: Proxy Chain (Multiple X-Forwarded-For Values)
+
+Test how ServicePulse handles multiple proxies in the chain.
+
+**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:5291/debug/request-info | json
+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:8081/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 5: Multiple Proto and Host Values
+
+Test how ServicePulse handles multiple values in `X-Forwarded-Proto` and `X-Forwarded-Host` headers, simulating requests that passed through multiple proxies with different configurations.
+
+**Test with curl (simulating multiple proto/host values):**
+
+```cmd
+curl -H "X-Forwarded-Proto: https, http" -H "X-Forwarded-Host: example.com, proxy.internal, lb.internal" -H "X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1" http://localhost:5291/debug/request-info | json
+curl -H "X-Forwarded-Proto: https, http" -H "X-Forwarded-Host: example.com, proxy.internal, lb.internal" -H "X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1" http://localhost:8081/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": []
+ }
+}
+```
+
+When multiple values are present, the middleware uses the leftmost (first) value from each header, which represents the original client's values. The `scheme` is `https` (first value from `X-Forwarded-Proto`), `host` is `example.com` (first value from `X-Forwarded-Host`), and `remoteIpAddress` is `203.0.113.50` (first value from `X-Forwarded-For`).
+
+#### Scenario 6: 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.
+
+**Test with curl (only X-Forwarded-Proto):**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" http://localhost:5291/debug/request-info | json
+curl -H "X-Forwarded-Proto: https" http://localhost:8081/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "localhost:5291",
+ "remoteIpAddress": "::1"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": true,
+ "knownProxies": [],
+ "knownNetworks": []
+ }
+}
+```
+
+Only the `scheme` changed to `https`. The `host` remains `localhost:5291` and `remoteIpAddress` remains `::1` because those headers weren't sent. Each header is processed independently.
+
+---
+
+### Known Proxies Only
+
+These scenarios configure specific IP addresses as trusted proxies.
+
+> [!NOTE]
+> Setting known proxies automatically disables trust all proxies. Both IPv4 (`127.0.0.1`) and IPv6 (`::1`) loopback addresses are included since curl may use either.
+
+#### Scenario 7: Trusted Proxy (Localhost)
+
+Only accept forwarded headers from specific IP addresses.
+
+**Start the application:**
+
+**.NET 8:**
+
+```cmd
+set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true
+set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1,::1
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+cd src\ServicePulse
+dotnet run
+```
+
+**.NET Framework:**
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheadersknownproxies=127.0.0.1,::1
+```
+
+**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:5291/debug/request-info | json
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:8081/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 8: Proxy Chain with Known Proxies (ForwardLimit = 1)
+
+Test how ServicePulse 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.
+
+**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:5291/debug/request-info | json
+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:8081/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 Scenarios 4-5 where `TrustAllProxies = true` returns the original client IP.
+
+#### Scenario 9: Unknown Proxy Rejected
+
+Configure a known proxy that doesn't match the request source to verify headers are ignored.
+
+**Start the application:**
+
+**.NET 8:**
+
+```cmd
+set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true
+set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES=192.168.1.100
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+cd src\ServicePulse
+dotnet run
+```
+
+**.NET Framework:**
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheadersknownproxies=192.168.1.100
+```
+
+**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:5291/debug/request-info | json
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:8081/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "http",
+ "host": "localhost:5291",
+ "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 10: 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.
+
+**Start the application:**
+
+**.NET 8:**
+
+```cmd
+set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true
+set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+cd src\ServicePulse
+dotnet run
+```
+
+**.NET Framework:**
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheadersknownproxies=127.0.0.1
+```
+
+> [!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:5291/debug/request-info | json
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:8081/debug/request-info | json
+```
+
+**Expected output (if curl uses IPv6):**
+
+```json
+{
+ "processed": {
+ "scheme": "http",
+ "host": "localhost:5291",
+ "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`.
+
+> [!NOTE]
+> If your output shows headers were applied, curl is using IPv4. The behavior depends on your system's DNS resolution for `localhost`.
+
+---
+
+### Known Networks (CIDR)
+
+These scenarios configure trusted network ranges using CIDR notation.
+
+> [!NOTE]
+> Both IPv4 (`127.0.0.0/8`) and IPv6 (`::1/128`) loopback networks are included since curl may use either.
+
+#### Scenario 11: Trusted Network (Localhost)
+
+Trust all proxies within a network range.
+
+**Start the application:**
+
+**.NET 8:**
+
+```cmd
+set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true
+set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS=127.0.0.0/8,::1/128
+
+cd src\ServicePulse
+dotnet run
+```
+
+**.NET Framework:**
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheadersknownnetworks=127.0.0.0/8,::1/128
+```
+
+**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:5291/debug/request-info | json
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:8081/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 12: Unknown Network Rejected
+
+Configure a known network that doesn't match the request source to verify headers are ignored.
+
+**Start the application:**
+
+**.NET 8:**
+
+```cmd
+set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true
+set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS=10.0.0.0/8,192.168.0.0/16
+
+cd src\ServicePulse
+dotnet run
+```
+
+**.NET Framework:**
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheadersknownnetworks=10.0.0.0/8,192.168.0.0/16
+```
+
+**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:5291/debug/request-info | json
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:8081/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "http",
+ "host": "localhost:5291",
+ "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.
+
+---
+
+### Combined Known Proxies and Networks
+
+This scenario uses both `KnownProxies` and `KnownNetworks` together.
+
+#### Scenario 13: Combined Configuration
+
+**Start the application:**
+
+**.NET 8:**
+
+```cmd
+set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true
+set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES=192.168.1.100
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS=127.0.0.0/8,::1/128
+
+cd src\ServicePulse
+dotnet run
+```
+
+**.NET Framework:**
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheadersknownproxies=192.168.1.100 --forwardedheadersknownnetworks=127.0.0.0/8,::1/128
+```
+
+**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:5291/debug/request-info | json
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:8081/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.
+
+---
+
+### Forwarded Headers Disabled
+
+This scenario completely disables forwarded headers processing.
+
+#### Scenario 14: Disabled
+
+**Start the application:**
+
+**.NET 8:**
+
+```cmd
+set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=false
+set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+cd src\ServicePulse
+dotnet run
+```
+
+**.NET Framework:**
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheadersenabled=false
+```
+
+**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:5291/debug/request-info | json
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:8081/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "http",
+ "host": "localhost:5291",
+ "remoteIpAddress": "::1"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "203.0.113.50",
+ "xForwardedProto": "https",
+ "xForwardedHost": "example.com"
+ },
+ "configuration": {
+ "enabled": false,
+ "trustAllProxies": true,
+ "knownProxies": [],
+ "knownNetworks": []
+ }
+}
+```
+
+Headers are ignored because forwarded headers processing is disabled entirely. Notice `enabled` is `false` in the configuration. The `trustAllProxies` value defaults to `true` but is irrelevant when forwarded headers are disabled.
+
+## Cleanup (.NET 8 only)
+
+After testing with .NET 8, clear the environment variables:
+
+**Command Prompt (cmd):**
+
+```cmd
+set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=
+set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS=
+```
+
+> [!NOTE]
+> .NET Framework uses command-line arguments, so no cleanup is needed - just stop the application.
+
+## See Also
+
+- [Forwarded Headers Configuration](https://docs.particular.net/servicepulse/security/configuration/forward-headers#configuration) - Configuration reference for forwarded headers
+- [NGINX Testing](nginx-testing.md) - Testing with a real reverse proxy (NGINX)
+- [Hosting Options](hosting-options.md) - General hosting configuration guide
diff --git a/docs/hosting-options.md b/docs/hosting-options.md
new file mode 100644
index 0000000000..8e901bdc18
--- /dev/null
+++ b/docs/hosting-options.md
@@ -0,0 +1,83 @@
+# ServicePulse Hosting Options
+
+There are several ways to host ServicePulse:
+
+## 1. Docker Container (Modern - Recommended)
+
+Uses the **ServicePulse** project (.NET 8.0 + ASP.NET Core)
+
+```bash
+docker run -p 9090:9090 \
+ -e SERVICECONTROL_URL="http://servicecontrol:33333/api/" \
+ -e MONITORING_URL="http://servicecontrol-monitoring:33633/" \
+ particular/servicepulse:latest
+```
+
+**Features:**
+
+- Built-in YARP reverse proxy (proxies `/api/` and `/monitoring-api/` to backends)
+- Cross-platform (linux/amd64, linux/arm64)
+- Environment variable configuration
+- Can disable reverse proxy with `ENABLE_REVERSE_PROXY=false`
+
+## 2. ServicePulse.Host - Windows Service (Legacy)
+
+Uses **ServicePulse.Host** project (.NET Framework 4.8 + OWIN)
+
+```cmd
+REM Run interactively
+ServicePulse.Host.exe --url="http://localhost:9090"
+
+REM Install as Windows Service
+ServicePulse.Host.exe --install --url="http://localhost:9090" --serviceControlUrl="http://localhost:33333/api" --serviceControlMonitoringUrl="http://localhost:33633"
+
+REM Uninstall
+ServicePulse.Host.exe --uninstall
+```
+
+**Features:**
+
+- Self-hosted HTTP server via OWIN
+- Runs as Windows Service or console app
+- Requires URL ACL reservation (`netsh http add urlacl`)
+- No reverse proxy - frontend makes direct CORS calls to ServiceControl
+
+## 3. IIS Hosting (Extract Mode)
+
+Extract static files for hosting in IIS or any web server:
+
+```cmd
+ServicePulse.Host.exe --extract --serviceControlUrl="http://localhost:33333/api" --serviceControlMonitoringUrl="http://localhost:33633" --outpath="C:\inetpub\wwwroot\servicepulse"
+```
+
+This creates a standalone SPA with URLs baked into `app.constants.js`. Requires ServiceControl to have CORS enabled.
+
+## 4. Windows Installer
+
+The `Setup` project creates an MSI/EXE installer that:
+
+- Installs ServicePulse.Host as a Windows Service
+- Configures URL ACL automatically
+- Default port: 9090
+
+## 5. ASP.NET Core in IIS
+
+The modern **ServicePulse** project can be hosted in IIS via ASP.NET Core Module, providing reverse proxy capabilities.
+
+## 6. Particular.PlatformSample.ServicePulse
+
+Not a hosting option - this is a NuGet package that bundles ServicePulse for embedding in the Particular Platform Sample.
+
+## Key Differences
+
+| Option | Technology | Reverse Proxy | Platform |
+|---------------------|-----------------|---------------|----------------|
+| Docker/ASP.NET Core | .NET 8.0 | Yes (YARP) | Cross-platform |
+| ServicePulse.Host | .NET 4.8 + OWIN | No | Windows only |
+| IIS Extract | Static files | No | Any web server |
+
+## See Also
+
+- [HTTPS Configuration](https://docs.particular.net/servicepulse/security/configuration/tls#configuration) - Configure direct HTTPS for either platform
+- [Forwarded Headers Configuration](https://docs.particular.net/servicepulse/security/configuration/forward-headers#configuration) - Configure forwarded headers when behind a reverse proxy
+- [HTTPS Testing](https-testing.md) - Test HTTPS configuration locally
diff --git a/docs/https-testing.md b/docs/https-testing.md
new file mode 100644
index 0000000000..28a1d60388
--- /dev/null
+++ b/docs/https-testing.md
@@ -0,0 +1,364 @@
+# Local Testing with Direct HTTPS
+
+This guide provides scenario-based tests for ServicePulse's direct HTTPS features. Use this to verify 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 ServicePulse. When running with direct HTTPS, ServicePulse only binds to a single port (HTTPS). To test HTTP to HTTPS redirection, see [Reverse Proxy Testing](nginx-testing.md). HSTS should not be tested on localhost because browsers cache the HSTS policy, which could break other local development. To test HSTS, use the [NGINX reverse proxy setup](nginx-testing.md) with a custom hostname (`servicepulse.localhost`).
+
+## Application Reference
+
+| Application | Project Directory | Default Port | Configuration |
+|------------------------------------|-------------------------|--------------|---------------------------------------------------|
+| ServicePulse (.NET 8) | `src\ServicePulse` | 5291 | Environment variables with `SERVICEPULSE_` prefix |
+| ServicePulse.Host (.NET Framework) | `src\ServicePulse.Host` | 9090 | Command-line arguments with `--` prefix |
+
+## HTTPS Configuration Reference
+
+See [ServicePulse TLS](https://docs.particular.net/servicepulse/security/configuration/tls).
+
+## .NET 8 Prerequisites
+
+- ServicePulse built locally (see [main README for instructions](../README.md#setting-up-the-project-for-development))
+- ServicePulse built locally (see main README for build instructions)
+- curl (included with Windows 10/11)
+
+### 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 PFX Certificates
+
+Kestrel used in the .NET 8 host requires certificates in PFX format. Use mkcert to generate them:
+
+```cmd
+mkcert -install
+cd .local\certs
+mkcert -p12-file localhost.pfx -pkcs12 localhost 127.0.0.1 ::1 servicepulse
+```
+
+When prompted for a password, you can use an empty password by pressing Enter, or set a password and note it for the configuration step (default is `changeit`).
+
+## .NET Framework Prerequisites
+
+ServicePulse.Host (.NET Framework) uses Windows HttpListener which requires additional setup.
+
+### Build the Frontend and ServicePulse.Host
+
+ServicePulse.Host embeds the frontend files into the assembly at build time. The easiest way to build everything is to run the full build script from the repository root:
+
+```powershell
+PowerShell -File .\build.ps1
+```
+
+Alternatively, build manually:
+
+```cmd
+cd src\Frontend
+npm install
+npm run build
+
+cd ..\..
+xcopy /E /I /Y src\Frontend\dist src\ServicePulse.Host\app
+cd src\ServicePulse.Host
+dotnet build
+```
+
+> [!NOTE]
+> The frontend files must be copied to `src/ServicePulse.Host/app/` *before* building because they are embedded into the assembly at compile time.
+
+### Import Certificate to Windows Store (Administrator)
+
+The certificate must be imported into the Windows certificate store:
+
+```powershell
+$password = ConvertTo-SecureString -String "" -Force -AsPlainText
+Import-PfxCertificate -FilePath ".local\certs\localhost.pfx" -CertStoreLocation Cert:\LocalMachine\My -Password $password
+```
+
+### Get Certificate Thumbprint (Administrator)
+
+```powershell
+(Get-ChildItem -Path Cert:\LocalMachine\My | Where-Object { $_.Subject -like "*localhost*" }).Thumbprint
+```
+
+Copy this thumbprint for the next step.
+
+### Bind Certificate to Port (Administrator)
+
+Run these commands in an **elevated (Administrator) command prompt**:
+
+```cmd
+netsh http add sslcert ipport=0.0.0.0:9090 certhash=YOUR_THUMBPRINT appid={00000000-0000-0000-0000-000000000000}
+netsh http add urlacl url=https://+:9090/ user=Everyone
+```
+
+Replace `YOUR_THUMBPRINT` with the thumbprint from the previous step.
+
+## Test Scenarios
+
+---
+
+### HTTPS Enabled (.NET 8)
+
+> [!NOTE]
+> Complete the [.NET 8 Prerequisites](#net-8-prerequisites) section first.
+
+**Start the application:**
+
+```cmd
+set SERVICEPULSE_HTTPS_ENABLED=true
+set SERVICEPULSE_HTTPS_CERTIFICATEPATH=C:\Users\warwi\source\repos\Particular\ServicePulse\.local\certs\localhost.pfx
+set SERVICEPULSE_HTTPS_CERTIFICATEPASSWORD=changeit
+set SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICEPULSE_HTTPS_PORT=
+set SERVICEPULSE_HTTPS_ENABLEHSTS=
+
+cd src\ServicePulse
+dotnet run
+```
+
+#### Scenario 1: Basic HTTPS Connectivity
+
+Verify that HTTPS is working with a valid certificate.
+
+**Test with curl:**
+
+```cmd
+curl --ssl-no-revoke -v https://localhost:5291 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: renegotiating SSL/TLS connection
+* schannel: SSL/TLS connection renegotiated
+< HTTP/1.1 200 OK
+```
+
+The request succeeds over HTTPS. The exact SSL output varies by curl version, but you should see `HTTP/1.1 200 OK` confirming success.
+
+#### Scenario 2: HTTP Disabled
+
+Verify that HTTP requests fail when only HTTPS is enabled.
+
+**Test with curl (HTTP):**
+
+```cmd
+curl http://localhost:5291
+```
+
+**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 Enabled (.NET Framework)
+
+> [!NOTE]
+> Complete the [.NET Framework Prerequisites](#net-framework-prerequisites) section first.
+
+**Start the application:**
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url=https://localhost:9090 --httpsenabled=true
+```
+
+#### Scenario 3: Basic HTTPS Connectivity
+
+Verify that HTTPS is working with ServicePulse.Host.
+
+**Test with curl:**
+
+```cmd
+curl --ssl-no-revoke -v https://localhost:9090 2>&1 | findstr /C:"HTTP/" /C:"SSL"
+```
+
+**Expected output:**
+
+```text
+* schannel: renegotiating SSL/TLS connection
+* schannel: SSL/TLS connection renegotiated
+< HTTP/1.1 200 OK
+```
+
+The request succeeds over HTTPS. You should see `HTTP/1.1 200 OK` confirming success.
+
+#### Scenario 4: HTTP Disabled
+
+Verify that HTTP requests fail when HTTPS is configured.
+
+**Test with curl (HTTP):**
+
+```cmd
+curl http://localhost:9090
+```
+
+**Expected output (one of):**
+
+```text
+curl: (52) Empty reply from server
+curl: (56) Recv failure: Connection was reset
+```
+
+HTTP requests fail because HttpListener is configured for HTTPS only. The exact error varies depending on timing.
+
+## Cleanup
+
+### Clear Environment Variables (.NET 8)
+
+After testing, clear the environment variables:
+
+**Command Prompt (cmd):**
+
+```cmd
+set SERVICEPULSE_HTTPS_ENABLED=
+set SERVICEPULSE_HTTPS_CERTIFICATEPATH=
+set SERVICEPULSE_HTTPS_CERTIFICATEPASSWORD=
+set SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICEPULSE_HTTPS_PORT=
+set SERVICEPULSE_HTTPS_ENABLEHSTS=
+```
+
+### Cleanup (.NET Framework)
+
+To remove the SSL binding and URL ACL:
+
+```cmd
+netsh http delete sslcert ipport=0.0.0.0:9090
+netsh http delete urlacl url=https://+:9090/
+```
+
+To remove the certificate from the store:
+
+```powershell
+Get-ChildItem -Path Cert:\LocalMachine\My | Where-Object { $_.Subject -like "*localhost*" } | Remove-Item
+```
+
+## Troubleshooting
+
+### Certificate not found
+
+Ensure the `SERVICEPULSE_HTTPS_CERTIFICATEPATH` is an absolute path and the file exists.
+
+### Certificate password incorrect
+
+If you set a password when generating the PFX, ensure it matches the configured password.
+
+### Certificate errors in browser
+
+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:5291
+```
+
+### Port already in use
+
+Ensure no other process is using the ServicePulse port (default 5291 for .NET 8, 9090 for .NET Framework).
+
+### HttpListener Access Denied (.NET Framework)
+
+Ensure you've created the URL ACL reservation:
+
+```cmd
+netsh http add urlacl url=https://+:9090/ user=Everyone
+```
+
+### SSL Binding Failed (.NET Framework)
+
+Ensure:
+
+1. The certificate is imported to `Cert:\LocalMachine\My`
+2. The thumbprint is correct (no spaces)
+3. You're running as Administrator
+
+### No response from ServicePulse.Host HTTPS (.NET Framework)
+
+If `curl --ssl-no-revoke https://localhost:9090` hangs with no response:
+
+1. **Verify the application started successfully:**
+ - Check the console output for any errors when starting ServicePulse.Host.exe
+ - The application should display a message indicating it's listening
+
+2. **Verify the SSL certificate binding:**
+
+ ```cmd
+ netsh http show sslcert ipport=0.0.0.0:9090
+ ```
+
+ The output should show the certificate hash and application ID. If not found, re-add the binding.
+
+3. **Verify the URL ACL:**
+
+ ```cmd
+ netsh http show urlacl url=https://+:9090/
+ ```
+
+ The output should show the reserved URL. If not found, re-add the URL ACL.
+
+4. **Check for thumbprint issues:**
+ - Thumbprints must have no spaces
+ - The PowerShell command in the prerequisites outputs the thumbprint without spaces
+ - Example: `A1B2C3D4E5F6...` (not `A1 B2 C3 D4 E5 F6...`)
+
+5. **Verify the certificate is valid:**
+
+ ```powershell
+ Get-ChildItem -Path Cert:\LocalMachine\My | Where-Object { $_.Subject -like "*localhost*" } | Select-Object Subject, Thumbprint, NotBefore, NotAfter
+ ```
+
+6. **Try removing and re-adding the SSL binding:**
+
+ ```cmd
+ netsh http delete sslcert ipport=0.0.0.0:9090
+ netsh http add sslcert ipport=0.0.0.0:9090 certhash=YOUR_THUMBPRINT appid={00000000-0000-0000-0000-000000000000}
+ ```
+
+7. **Check Windows Event Viewer:**
+ - Open Event Viewer (`eventvwr.msc`)
+ - Navigate to Windows Logs > Application
+ - Look for errors related to HttpListener or SSL
+
+## See Also
+
+- [HTTPS Configuration](https://docs.particular.net/servicepulse/security/configuration/tls#configuration) - Configuration reference for all HTTPS settings
+- [Forwarded Headers Configuration](https://docs.particular.net/servicepulse/security/configuration/forward-headers#configuration) - Configure forwarded headers when behind a reverse proxy
+- [Forwarded Headers Testing](forwarded-headers-testing.md) - Testing forwarded headers without a reverse proxy
+- [Reverse Proxy Testing](nginx-testing.md) - Testing with NGINX reverse proxy
diff --git a/docs/nginx-testing.md b/docs/nginx-testing.md
new file mode 100644
index 0000000000..9d34419f85
--- /dev/null
+++ b/docs/nginx-testing.md
@@ -0,0 +1,608 @@
+# Local Testing with NGINX Reverse Proxy
+
+This guide provides scenario-based tests for ServicePulse 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)
+
+## Application Reference
+
+| Application | Project Directory | Default Port | Hostname | Configuration |
+|------------------------------------|-------------------------|--------------|-------------------------------|---------------------------------------------------|
+| ServicePulse (.NET 8) | `src\ServicePulse` | 5291 | `servicepulse.localhost` | Environment variables with `SERVICEPULSE_` prefix |
+| ServicePulse.Host (.NET Framework) | `src\ServicePulse.Host` | 8081 | `servicepulse-host.localhost` | Command-line arguments with `--` prefix |
+
+## Configuration Reference
+
+See [ServicePulse Security](https://docs.particular.net/servicepulse/security).
+
+## Prerequisites
+
+- [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed and running
+- [mkcert](https://github.com/FiloSottile/mkcert) for generating local development certificates
+- ServicePulse built locally (see [main README for instructions](../README.md#setting-up-the-project-for-development))
+- curl (included with Windows 10/11)
+
+### 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 servicepulse.pem -key-file servicepulse-key.pem servicepulse.localhost servicepulse-host.localhost localhost
+```
+
+### Step 3: Create Docker Compose Configuration
+
+Create `.local/compose.yml`:
+
+```yaml
+services:
+ reverse-proxy-servicepulse:
+ image: nginx:alpine
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - ./nginx.conf:/etc/nginx/nginx.conf:ro
+ - ./certs/servicepulse.pem:/etc/nginx/certs/servicepulse.pem:ro
+ - ./certs/servicepulse-key.pem:/etc/nginx/certs/servicepulse-key.pem:ro
+```
+
+### Step 4: Create NGINX Configuration
+
+Create `.local/nginx.conf`:
+
+```nginx
+events { worker_connections 1024; }
+
+http {
+ # Shared SSL Settings
+ ssl_certificate /etc/nginx/certs/servicepulse.pem;
+ ssl_certificate_key /etc/nginx/certs/servicepulse-key.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_ciphers HIGH:!aNULL:!MD5;
+
+ # ServicePulse (.NET 8) - HTTPS
+ server {
+ listen 443 ssl;
+ server_name servicepulse.localhost;
+
+ location / {
+ proxy_pass http://host.docker.internal:5291;
+
+ # 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;
+ }
+ }
+
+ # ServicePulse (.NET 8) - HTTP (for testing HTTP-to-HTTPS redirect)
+ server {
+ listen 80;
+ server_name servicepulse.localhost;
+
+ location / {
+ proxy_pass http://host.docker.internal:5291;
+
+ # 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;
+ }
+ }
+
+ # ServicePulse.Host (.NET Framework) - HTTPS
+ server {
+ listen 443 ssl;
+ server_name servicepulse-host.localhost;
+
+ location / {
+ proxy_pass http://host.docker.internal:8081;
+
+ # 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;
+ }
+ }
+
+ # ServicePulse.Host (.NET Framework) - HTTP (for testing HTTP-to-HTTPS redirect)
+ server {
+ listen 80;
+ server_name servicepulse-host.localhost;
+
+ location / {
+ proxy_pass http://host.docker.internal:8081;
+
+ # 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 servicepulse.localhost
+127.0.0.1 servicepulse-host.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/
+ ├── servicepulse.pem
+ └── servicepulse-key.pem
+```
+
+## .NET Framework Prerequisites
+
+ServicePulse.Host (.NET Framework) requires a URL ACL reservation.
+
+### Build the Frontend and ServicePulse.Host
+
+ServicePulse.Host embeds the frontend files into the assembly at build time. The easiest way to build everything is to run the full build script from the repository root:
+
+```powershell
+PowerShell -File .\build.ps1
+```
+
+Alternatively, build manually:
+
+```cmd
+cd src\Frontend
+npm install
+npm run build
+
+cd ..\..
+xcopy /E /I /Y src\Frontend\dist src\ServicePulse.Host\app
+cd src\ServicePulse.Host
+dotnet build
+```
+
+### Create URL ACL Reservation (Administrator)
+
+Run in an **elevated (Administrator) command prompt**:
+
+```cmd
+netsh http add urlacl url=http://+:8081/ user=Everyone
+```
+
+## Test Scenarios
+
+> [!IMPORTANT]
+> ServicePulse must be running before testing. A 502 Bad Gateway error means NGINX cannot reach ServicePulse. 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 (.NET 8)
+
+Verify that HTTPS is working through the reverse proxy.
+
+**Clear environment variables and start ServicePulse:**
+
+```cmd
+set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=
+set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICEPULSE_HTTPS_PORT=
+set SERVICEPULSE_HTTPS_ENABLEHSTS=
+
+cd src\ServicePulse
+dotnet run
+```
+
+**Test with curl:**
+
+```cmd
+curl -k -v https://servicepulse.localhost 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: HTTPS Access (.NET Framework)
+
+Verify that HTTPS is working through the reverse proxy with ServicePulse.Host.
+
+> [!IMPORTANT]
+> Complete the [.NET Framework Prerequisites](#net-framework-prerequisites) section first.
+
+**Start ServicePulse.Host:**
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url=http://localhost:8081
+```
+
+**Test with curl:**
+
+```cmd
+curl -k -v https://servicepulse-host.localhost 2>&1 | findstr /C:"HTTP/"
+```
+
+**Expected output:**
+
+```text
+< HTTP/1.1 200 OK
+```
+
+The request succeeds over HTTPS through the NGINX reverse proxy.
+
+### Scenario 3: Forwarded Headers Processing (.NET 8)
+
+Verify that forwarded headers are being processed correctly.
+
+**Set environment variables and start ServicePulse:**
+
+```cmd
+set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true
+set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=true
+set SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICEPULSE_HTTPS_PORT=
+set SERVICEPULSE_HTTPS_ENABLEHSTS=
+
+cd src\ServicePulse
+dotnet run
+```
+
+**Test with curl:**
+
+```cmd
+curl -k https://servicepulse.localhost/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "servicepulse.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 `servicepulse.localhost` (from `X-Forwarded-Host`)
+- `rawHeaders` are empty because the middleware consumed them (trusted proxy)
+
+### Scenario 4: Forwarded Headers Processing (.NET Framework)
+
+Verify that forwarded headers are being processed correctly with ServicePulse.Host.
+
+**Start ServicePulse.Host with forwarded headers enabled:**
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheaderstrustallproxies=true
+```
+
+**Test with curl:**
+
+```cmd
+curl -k https://servicepulse-host.localhost/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "servicepulse-host.localhost",
+ "remoteIpAddress": "172.x.x.x"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": true,
+ "knownProxies": [],
+ "knownNetworks": []
+ }
+}
+```
+
+### Scenario 5: HTTP to HTTPS Redirect (.NET 8)
+
+Verify that HTTP requests are redirected to HTTPS.
+
+**Set environment variables and start ServicePulse:**
+
+```cmd
+set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true
+set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=true
+set SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS=true
+set SERVICEPULSE_HTTPS_PORT=443
+set SERVICEPULSE_HTTPS_ENABLEHSTS=
+
+cd src\ServicePulse
+dotnet run
+```
+
+**Test with curl:**
+
+```cmd
+curl -v http://servicepulse.localhost 2>&1 | findstr /i location
+```
+
+**Expected output:**
+
+```text
+< Location: https://servicepulse.localhost/
+```
+
+HTTP requests are redirected to HTTPS with a 307 (Temporary Redirect) status.
+
+### Scenario 6: HTTP to HTTPS Redirect (.NET Framework)
+
+Verify that HTTP requests are redirected to HTTPS with ServicePulse.Host.
+
+**Start ServicePulse.Host with redirect enabled:**
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheaderstrustallproxies=true --httpsredirecthttptohttps=true --httpsport=443
+```
+
+**Test with curl:**
+
+```cmd
+curl -v http://servicepulse-host.localhost 2>&1 | findstr /i location
+```
+
+**Expected output:**
+
+```text
+< Location: https://servicepulse-host.localhost/
+```
+
+HTTP requests are redirected to HTTPS with a 307 (Temporary Redirect) status.
+
+### Scenario 7: HSTS (.NET 8)
+
+Verify that the HSTS header is included in HTTPS responses.
+
+> [!NOTE]
+> You must use `--environment Production` because HSTS is disabled in Development.
+
+**Set environment variables and start ServicePulse:**
+
+```cmd
+set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true
+set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=true
+set SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICEPULSE_HTTPS_PORT=
+set SERVICEPULSE_HTTPS_ENABLEHSTS=true
+
+cd src\ServicePulse
+dotnet run --environment Production
+```
+
+**Test with curl:**
+
+```cmd
+curl -k -v https://servicepulse.localhost 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.
+
+### Scenario 8: HSTS (.NET Framework)
+
+Verify that the HSTS header is included in HTTPS responses with ServicePulse.Host.
+
+**Start ServicePulse.Host with HSTS enabled:**
+
+```cmd
+cd src\ServicePulse.Host\bin\Debug\net48
+ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheaderstrustallproxies=true --httpsenablehsts=true
+```
+
+**Test with curl:**
+
+```cmd
+curl -k -v https://servicepulse-host.localhost 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.
+
+## Cleanup
+
+### Stop NGINX
+
+```cmd
+docker compose -f .local/compose.yml down
+```
+
+### Clear Environment Variables (.NET 8)
+
+After testing, clear the environment variables:
+
+**Command Prompt (cmd):**
+
+```cmd
+set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=
+set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICEPULSE_HTTPS_PORT=
+set SERVICEPULSE_HTTPS_ENABLEHSTS=
+```
+
+**PowerShell:**
+
+```powershell
+$env:SERVICEPULSE_FORWARDEDHEADERS_ENABLED = $null
+$env:SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES = $null
+$env:SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS = $null
+$env:SERVICEPULSE_HTTPS_PORT = $null
+$env:SERVICEPULSE_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 servicepulse.localhost
+127.0.0.1 servicepulse-host.localhost
+```
+
+## Troubleshooting
+
+### 502 Bad Gateway
+
+This error means NGINX cannot reach ServicePulse. Check:
+
+1. ServicePulse is running (`dotnet run` in `src/ServicePulse`)
+2. ServicePulse is accessible directly: `curl http://localhost:5291`
+3. Docker Desktop is running and `host.docker.internal` resolves correctly
+
+### "Connection refused" errors
+
+Ensure ServicePulse is running and listening on the expected port (5291 for .NET 8, 8081 for .NET Framework).
+
+### Conflicting environment variables from direct HTTPS testing
+
+If you previously tested [direct HTTPS](https-testing.md), you may have environment variables set that conflict with reverse proxy testing. Clear them before running:
+
+**Command Prompt (cmd):**
+
+```cmd
+set SERVICEPULSE_HTTPS_ENABLED=
+set SERVICEPULSE_HTTPS_CERTIFICATEPATH=
+set SERVICEPULSE_HTTPS_CERTIFICATEPASSWORD=
+```
+
+**PowerShell:**
+
+```powershell
+$env:SERVICEPULSE_HTTPS_ENABLED = $null
+$env:SERVICEPULSE_HTTPS_CERTIFICATEPATH = $null
+$env:SERVICEPULSE_HTTPS_CERTIFICATEPASSWORD = $null
+```
+
+For reverse proxy testing, ServicePulse should run on HTTP (not HTTPS) since NGINX handles SSL termination.
+
+### Headers not being applied
+
+1. Verify `SERVICEPULSE_FORWARDEDHEADERS_ENABLED` is `true`
+2. Verify `SERVICEPULSE_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 ServicePulse port is not blocked by Windows Firewall
+
+### Debug endpoint not available
+
+The `/debug/request-info` endpoint is only available:
+
+- ServicePulse (.NET 8): When running in Development environment
+- ServicePulse.Host (.NET Framework): In DEBUG builds when running interactively
+
+## See Also
+
+- [HTTPS Configuration](https://docs.particular.net/servicepulse/security/configuration/tls#configuration) - Configuration reference for all HTTPS settings
+- [Forwarded Headers Configuration](https://docs.particular.net/servicepulse/security/configuration/forward-headers#configuration) - Configuration reference for all forwarded headers settings
+- [HTTPS Testing](https-testing.md) - Testing direct HTTPS without a reverse proxy
+- [Forwarded Headers Testing](forwarded-headers-testing.md) - Testing forwarded headers without a reverse proxy
+- [Authentication Testing](authentication-testing.md) - Testing OIDC authentication
diff --git a/src/Frontend/eslint-rules/no-raw-fetch.ts b/src/Frontend/eslint-rules/no-raw-fetch.ts
new file mode 100644
index 0000000000..42d16024fa
--- /dev/null
+++ b/src/Frontend/eslint-rules/no-raw-fetch.ts
@@ -0,0 +1,48 @@
+import type { Rule } from "eslint";
+
+/**
+ * ESLint rule to disallow raw fetch() calls.
+ * Use authFetch from useAuthenticatedFetch.ts instead.
+ */
+const rule: Rule.RuleModule = {
+ meta: {
+ type: "problem",
+ docs: {
+ description: "Disallow raw fetch() calls. Use authFetch from useAuthenticatedFetch.ts instead.",
+ },
+ messages: {
+ noRawFetch: "Do not use raw fetch(). Use authFetch from '@/composables/useAuthenticatedFetch' instead.",
+ },
+ schema: [],
+ },
+ create(context) {
+ const sourceCode = context.sourceCode;
+
+ return {
+ CallExpression(node) {
+ // Check if it's a call to `fetch`
+ if (node.callee.type === "Identifier" && node.callee.name === "fetch") {
+ // Check if this file is the useAuthenticatedFetch composable itself
+ const filename = context.filename;
+ if (filename.includes("useAuthenticatedFetch")) {
+ return; // Allow fetch in the wrapper file itself
+ }
+
+ // Check if `fetch` is a local variable/parameter (not the global)
+ const scope = sourceCode.getScope(node);
+ const variable = scope.references.find((ref) => ref.identifier === node.callee);
+ if (variable && variable.resolved && variable.resolved.defs.length > 0) {
+ return; // It's a local variable, not the global fetch
+ }
+
+ context.report({
+ node,
+ messageId: "noRawFetch",
+ });
+ }
+ },
+ };
+ },
+};
+
+export default rule;
diff --git a/src/Frontend/eslint.config.mjs b/src/Frontend/eslint.config.mjs
index e5da58a06e..272d66996a 100644
--- a/src/Frontend/eslint.config.mjs
+++ b/src/Frontend/eslint.config.mjs
@@ -4,6 +4,13 @@ import tseslint from "typescript-eslint";
import pluginVue from "eslint-plugin-vue";
import pluginPromise from "eslint-plugin-promise";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
+import noRawFetch from "./eslint-rules/no-raw-fetch.ts";
+
+const localPlugin = {
+ rules: {
+ "no-raw-fetch": noRawFetch,
+ },
+};
export default tseslint.config(
{
@@ -12,6 +19,9 @@ export default tseslint.config(
{
files: ["**/*.{js,mjs,ts,vue}"],
languageOptions: { globals: globals.browser, ecmaVersion: "latest", parserOptions: { parser: tseslint.parser } },
+ plugins: {
+ local: localPlugin,
+ },
extends: [pluginJs.configs.recommended, ...tseslint.configs.recommended, ...pluginVue.configs["flat/essential"], pluginPromise.configs["flat/recommended"], eslintPluginPrettierRecommended],
rules: {
"no-duplicate-imports": "error",
@@ -24,6 +34,7 @@ export default tseslint.config(
"prefer-const": "error",
eqeqeq: ["error", "smart"],
"no-throw-literal": "warn",
+ "local/no-raw-fetch": "error",
},
}
);
diff --git a/src/Frontend/package-lock.json b/src/Frontend/package-lock.json
index beaeab5f9d..37e69bddbe 100644
--- a/src/Frontend/package-lock.json
+++ b/src/Frontend/package-lock.json
@@ -28,6 +28,7 @@
"diff": "8.0.3",
"hex-to-css-filter": "6.0.0",
"lossless-json": "4.3.0",
+ "oidc-client-ts": "3.4.1",
"pinia": "3.0.4",
"vue": "3.5.26",
"vue-codemirror6": "1.4.1",
@@ -3492,9 +3493,9 @@
}
},
"node_modules/alien-signals": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.0.tgz",
- "integrity": "sha512-yufC6VpSy8tK3I0lO67pjumo5JvDQVQyr38+3OHqe6CHl1t2VZekKZ7EKKZSqk0cRmE7U7tfZbpXiKNzuc+ckg==",
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz",
+ "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==",
"dev": true,
"license": "MIT"
},
@@ -4942,24 +4943,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -6068,6 +6051,15 @@
"node": ">=6"
}
},
+ "node_modules/jwt-decode": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
+ "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -6509,6 +6501,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/oidc-client-ts": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz",
+ "integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "jwt-decode": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/open": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
@@ -7503,6 +7507,24 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
"node_modules/tinyrainbow": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
@@ -8045,6 +8067,24 @@
"vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0"
}
},
+ "node_modules/vite/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
"node_modules/vitest": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.12.tgz",
diff --git a/src/Frontend/package.json b/src/Frontend/package.json
index 4cadeb7fef..58e2735ffb 100644
--- a/src/Frontend/package.json
+++ b/src/Frontend/package.json
@@ -40,6 +40,7 @@
"diff": "8.0.3",
"hex-to-css-filter": "6.0.0",
"lossless-json": "4.3.0",
+ "oidc-client-ts": "3.4.1",
"pinia": "3.0.4",
"vue": "3.5.26",
"vue-codemirror6": "1.4.1",
diff --git a/src/Frontend/public/silent-renew.html b/src/Frontend/public/silent-renew.html
new file mode 100644
index 0000000000..5cc4e681a0
--- /dev/null
+++ b/src/Frontend/public/silent-renew.html
@@ -0,0 +1,20 @@
+
+
+
+
+ Silent Renew
+
+
+
+
+
diff --git a/src/Frontend/src/App.vue b/src/Frontend/src/App.vue
index 1a69d6bc41..de55293cb5 100644
--- a/src/Frontend/src/App.vue
+++ b/src/Frontend/src/App.vue
@@ -1,18 +1,34 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Frontend/src/AuthApp.vue b/src/Frontend/src/AuthApp.vue
new file mode 100644
index 0000000000..e28c8544fd
--- /dev/null
+++ b/src/Frontend/src/AuthApp.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
Checking Authentication Details...
+
+
+
+
+
+
diff --git a/src/Frontend/src/assets/navbar.css b/src/Frontend/src/assets/navbar.css
index 4f44cf7d0b..ee78aab009 100644
--- a/src/Frontend/src/assets/navbar.css
+++ b/src/Frontend/src/assets/navbar.css
@@ -6,6 +6,8 @@
.navbar {
height: 60px;
flex-wrap: nowrap;
+ position: relative;
+ z-index: 1000;
}
.navbar-inverse {
diff --git a/src/Frontend/src/components/PageHeader.vue b/src/Frontend/src/components/PageHeader.vue
index e059894b22..093cff518f 100644
--- a/src/Frontend/src/components/PageHeader.vue
+++ b/src/Frontend/src/components/PageHeader.vue
@@ -13,8 +13,15 @@ import FeedbackButton from "@/components/FeedbackButton.vue";
import ThroughputMenuItem from "@/views/throughputreport/ThroughputMenuItem.vue";
import AuditMenuItem from "./audit/AuditMenuItem.vue";
import monitoringClient from "@/components/monitoring/monitoringClient";
+import UserProfileMenuItem from "@/components/UserProfileMenuItem.vue";
+import { useAuthStore } from "@/stores/AuthStore";
+import { storeToRefs } from "pinia";
const isMonitoringEnabled = monitoringClient.isMonitoringEnabled;
+
+const authStore = useAuthStore();
+const { authEnabled, isAuthenticated } = storeToRefs(authStore);
+
// prettier-ignore
const menuItems = computed(
() => [
@@ -45,6 +52,9 @@ const menuItems = computed(
+
+
+
diff --git a/src/Frontend/src/components/UserProfileMenuItem.vue b/src/Frontend/src/components/UserProfileMenuItem.vue
new file mode 100644
index 0000000000..c513fb9680
--- /dev/null
+++ b/src/Frontend/src/components/UserProfileMenuItem.vue
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
diff --git a/src/Frontend/src/components/configuration/PlatformConnections.vue b/src/Frontend/src/components/configuration/PlatformConnections.vue
index 6f1ad46fe5..2f97ee54ea 100644
--- a/src/Frontend/src/components/configuration/PlatformConnections.vue
+++ b/src/Frontend/src/components/configuration/PlatformConnections.vue
@@ -5,6 +5,7 @@ import FAIcon from "@/components/FAIcon.vue";
import useConnectionsAndStatsAutoRefresh from "@/composables/useConnectionsAndStatsAutoRefresh";
import serviceControlClient from "@/components/serviceControlClient";
import monitoringClient from "../monitoring/monitoringClient";
+import { authFetch } from "@/composables/useAuthenticatedFetch";
const { store: connectionStore } = useConnectionsAndStatsAutoRefresh();
const connectionState = connectionStore.connectionState;
@@ -24,7 +25,7 @@ async function testServiceControlUrl() {
if (localServiceControlUrl.value) {
testingServiceControl.value = true;
try {
- const response = await fetch(localServiceControlUrl.value);
+ const response = await authFetch(localServiceControlUrl.value);
serviceControlValid.value = response.ok && response.headers.has("X-Particular-Version");
} catch {
serviceControlValid.value = false;
@@ -43,7 +44,7 @@ async function testMonitoringUrl() {
}
try {
- const response = await fetch(localMonitoringUrl.value + "monitored-endpoints");
+ const response = await authFetch(localMonitoringUrl.value + "monitored-endpoints");
monitoringValid.value = response.ok && response.headers.has("X-Particular-Version");
} catch {
monitoringValid.value = false;
diff --git a/src/Frontend/src/components/monitoring/monitoringClient.ts b/src/Frontend/src/components/monitoring/monitoringClient.ts
index 7b94d105d1..45574ff28c 100644
--- a/src/Frontend/src/components/monitoring/monitoringClient.ts
+++ b/src/Frontend/src/components/monitoring/monitoringClient.ts
@@ -1,3 +1,4 @@
+import { authFetch } from "@/composables/useAuthenticatedFetch";
import { Endpoint, EndpointDetails } from "@/resources/MonitoringEndpoint";
export interface MetricsConnectionDetails {
@@ -56,7 +57,7 @@ class MonitoringClient {
if (this.isMonitoringDisabled) {
return;
}
- await fetch(`${this.url}monitored-instance/${endpointName}/${instanceId}`, {
+ await authFetch(`${this.url}monitored-instance/${endpointName}/${instanceId}`, {
method: "DELETE",
});
}
@@ -66,7 +67,7 @@ class MonitoringClient {
return false;
}
- const response = await fetch(`${this.url}`, {
+ const response = await authFetch(`${this.url}`, {
method: "OPTIONS",
});
@@ -94,7 +95,7 @@ class MonitoringClient {
return [];
}
- const response = await fetch(`${this.url}${suffix}`);
+ const response = await authFetch(`${this.url}${suffix}`);
const data = await response.json();
return [response, data];
diff --git a/src/Frontend/src/components/serviceControlClient.ts b/src/Frontend/src/components/serviceControlClient.ts
index 3e0ec2c183..a5d025b9dd 100644
--- a/src/Frontend/src/components/serviceControlClient.ts
+++ b/src/Frontend/src/components/serviceControlClient.ts
@@ -1,3 +1,5 @@
+import { authFetch } from "@/composables/useAuthenticatedFetch";
+
export interface ServiceControlInstanceConnection {
settings: { [key: string]: object };
errors: string[];
@@ -27,7 +29,7 @@ class ServiceControlClient {
}
public async fetchTypedFromServiceControl(suffix: string, signal?: AbortSignal): Promise<[Response, T]> {
- const response = await fetch(`${this.url}${suffix}`, { signal });
+ const response = await authFetch(`${this.url}${suffix}`, { signal });
if (!response.ok) throw new Error(response.statusText ?? "No response");
const data = await response.json();
@@ -42,7 +44,7 @@ class ServiceControlClient {
requestOptions.headers = { "Content-Type": "application/json" };
requestOptions.body = JSON.stringify(payload);
}
- return await fetch(`${this.url}${suffix}`, requestOptions);
+ return await authFetch(`${this.url}${suffix}`, requestOptions);
}
public async putToServiceControl(suffix: string, payload: object | null) {
@@ -53,14 +55,14 @@ class ServiceControlClient {
requestOptions.headers = { "Content-Type": "application/json" };
requestOptions.body = JSON.stringify(payload);
}
- return await fetch(`${this.url}${suffix}`, requestOptions);
+ return await authFetch(`${this.url}${suffix}`, requestOptions);
}
public async deleteFromServiceControl(suffix: string) {
const requestOptions: RequestInit = {
method: "DELETE",
};
- return await fetch(`${this.url}${suffix}`, requestOptions);
+ return await authFetch(`${this.url}${suffix}`, requestOptions);
}
public async patchToServiceControl(suffix: string, payload: object | null) {
@@ -71,7 +73,7 @@ class ServiceControlClient {
requestOptions.headers = { "Content-Type": "application/json" };
requestOptions.body = JSON.stringify(payload);
}
- return await fetch(`${this.url}${suffix}`, requestOptions);
+ return await authFetch(`${this.url}${suffix}`, requestOptions);
}
public async fetchFromServiceControl(suffix: string, options?: { cache?: RequestCache }) {
@@ -82,7 +84,7 @@ class ServiceControlClient {
Accept: "application/json",
},
};
- return await fetch(`${this.url}${suffix}`, requestOptions);
+ return await authFetch(`${this.url}${suffix}`, requestOptions);
}
public async getErrorMessagesCount(status: string) {
diff --git a/src/Frontend/src/composables/autoRefresh.ts b/src/Frontend/src/composables/autoRefresh.ts
index 92b6c06f73..23150f3323 100644
--- a/src/Frontend/src/composables/autoRefresh.ts
+++ b/src/Frontend/src/composables/autoRefresh.ts
@@ -1,7 +1,7 @@
import { watch, ref, shallowReadonly, type WatchStopHandle } from "vue";
import { useCounter, useDocumentVisibility, useTimeoutPoll } from "@vueuse/core";
-export default function useFetchWithAutoRefresh(name: string, fetch: () => Promise, intervalMs: number) {
+export default function useFetchWithAutoRefresh(name: string, fetchFn: () => Promise, intervalMs: number) {
let watchStop: WatchStopHandle | null = null;
const { count, inc, dec, reset } = useCounter(0);
const interval = ref(intervalMs);
@@ -11,7 +11,7 @@ export default function useFetchWithAutoRefresh(name: string, fetch: () => Promi
return;
}
isRefreshing.value = true;
- await fetch();
+ await fetchFn();
isRefreshing.value = false;
};
const { isActive, pause, resume } = useTimeoutPoll(
diff --git a/src/Frontend/src/composables/useAuth.ts b/src/Frontend/src/composables/useAuth.ts
new file mode 100644
index 0000000000..137d70d73b
--- /dev/null
+++ b/src/Frontend/src/composables/useAuth.ts
@@ -0,0 +1,162 @@
+import { useAuthStore } from "@/stores/AuthStore";
+import type { AuthConfig } from "@/types/auth";
+import { UserManager, type User } from "oidc-client-ts";
+
+let userManager: UserManager | null = null;
+
+/**
+ * Authentication composable using 'oidc-client-ts' package
+ * Supports any OIDC-compliant identity provider (Entra ID, Auth0, Okta, etc.)
+ */
+export function useAuth() {
+ const authStore = useAuthStore();
+
+ function initializeUserManager(config: AuthConfig): UserManager {
+ if (!userManager) {
+ userManager = new UserManager(config);
+
+ // Set up event handlers
+ userManager.events.addUserLoaded((user: User) => {
+ console.debug("User loaded:", user.profile);
+ authStore.setToken(user.access_token);
+ });
+
+ userManager.events.addUserUnloaded(() => {
+ console.debug("User unloaded");
+ authStore.clearToken();
+ });
+
+ userManager.events.addAccessTokenExpiring(async () => {
+ console.debug("Access token expiring, attempting silent renewal...");
+ try {
+ await userManager?.signinSilent();
+ } catch (error) {
+ console.error("Silent token renewal failed:", error);
+ }
+ });
+
+ userManager.events.addAccessTokenExpired(() => {
+ console.debug("Access token expired");
+ authStore.clearToken();
+ });
+
+ userManager.events.addSilentRenewError((error) => {
+ console.error("Silent renew error:", error);
+ });
+ }
+
+ return userManager;
+ }
+
+ /**
+ * Gets the current user from the UserManager
+ */
+ async function getUser(): Promise {
+ if (!userManager) {
+ return null;
+ }
+ return await userManager.getUser();
+ }
+
+ /**
+ * Attempts to authenticate the user
+ * This checks for existing authentication or handles the callback from the identity provider
+ */
+ async function authenticate(config: AuthConfig): Promise {
+ const manager = initializeUserManager(config);
+
+ try {
+ // Check if we're returning from the identity provider (callback)
+ // Look for specific OAuth parameters in the URL
+ const params = new URLSearchParams(window.location.search);
+ const hasCode = params.has("code");
+ const hasState = params.has("state");
+ const hasError = params.has("error");
+
+ if (hasCode && hasState) {
+ // This is an OAuth callback with authorization code
+ console.debug("Processing OAuth callback...");
+ authStore.setAuthenticating(true);
+ try {
+ const user = await manager.signinCallback();
+ console.debug("Signin callback successful");
+ if (user) {
+ authStore.setToken(user.access_token);
+ // Clean up URL by removing OAuth parameters
+ window.history.replaceState({}, document.title, window.location.pathname);
+ return true;
+ }
+ } catch (error) {
+ console.error("Signin callback error details:", {
+ error,
+ errorMessage: error instanceof Error ? error.message : "Unknown error",
+ errorStack: error instanceof Error ? error.stack : undefined,
+ });
+ authStore.setAuthError(error instanceof Error ? error.message : "Callback failed");
+ // Don't continue - callback failed, user needs to try again
+ return false;
+ } finally {
+ authStore.setAuthenticating(false);
+ }
+ } else if (hasError) {
+ // OAuth error in callback
+ const errorDescription = params.get("error_description") || params.get("error");
+ console.error("OAuth error:", errorDescription);
+ authStore.setAuthError(errorDescription || "Authentication failed");
+ return false;
+ }
+
+ // Check for existing valid user session
+ const user = await manager.getUser();
+ if (user && !user.expired) {
+ console.debug("Existing user session found", user.profile);
+ authStore.setToken(user.access_token);
+ return true;
+ }
+
+ // No valid session, initiate login
+ authStore.setAuthenticating(true);
+ await manager.signinRedirect();
+ return false; // Will redirect, so this won't actually return
+ } catch (error) {
+ authStore.setAuthenticating(false);
+ const errorMessage = error instanceof Error ? error.message : "Unknown authentication error";
+ authStore.setAuthError(errorMessage);
+ console.error("Authentication error:", error);
+ throw error;
+ }
+ }
+
+ /**
+ * Logs out the user and optionally redirects to the identity provider's logout endpoint
+ */
+ async function logout(redirectToIdp: boolean = true): Promise {
+ if (!userManager) {
+ authStore.clearToken();
+ return;
+ }
+
+ try {
+ if (redirectToIdp) {
+ // Sign out and redirect to the identity provider
+ await userManager.signoutRedirect();
+ } else {
+ // Remove local session only
+ await userManager.removeUser();
+ authStore.clearToken();
+ }
+ } catch (error) {
+ console.error("Logout error:", error);
+ authStore.clearToken();
+ }
+ }
+
+ return {
+ authenticate,
+ logout,
+ getUser,
+ isAuthenticated: authStore.isAuthenticated,
+ isAuthenticating: authStore.isAuthenticating,
+ authError: authStore.authError,
+ };
+}
diff --git a/src/Frontend/src/composables/useAuthenticatedFetch.ts b/src/Frontend/src/composables/useAuthenticatedFetch.ts
new file mode 100644
index 0000000000..2fa2d0cfa7
--- /dev/null
+++ b/src/Frontend/src/composables/useAuthenticatedFetch.ts
@@ -0,0 +1,38 @@
+import { useAuthStore } from "@/stores/AuthStore";
+
+const UNAUTHENTICATED_ENDPOINTS = ["/api/authentication/configuration"];
+
+function isUnauthenticatedEndpoint(url: string): boolean {
+ return UNAUTHENTICATED_ENDPOINTS.some((endpoint) => url.includes(endpoint));
+}
+
+/**
+ * Authenticated fetch wrapper that automatically includes JWT token
+ * in the Authorization header when authentication is enabled.
+ */
+export function authFetch(input: RequestInfo, init?: RequestInit): Promise {
+ const authStore = useAuthStore();
+ const url = typeof input === "string" ? input : input.url;
+
+ // Allow unauthenticated requests to specific endpoints
+ if (isUnauthenticatedEndpoint(url)) {
+ return fetch(input, init);
+ }
+
+ // If authentication is disabled, make request without token
+ if (!authStore.authEnabled) {
+ return fetch(input, init);
+ }
+
+ // If authentication is enabled, require a token
+ // potentially handle token refresh here if expired, however it shouldnt be required due to silent renew
+ const token = authStore.token;
+ if (!token) {
+ throw new Error("No authentication token available. Please authenticate first.");
+ }
+
+ const headers = new Headers(init?.headers);
+ headers.set("Authorization", `Bearer ${token}`);
+
+ return fetch(input, { ...init, headers });
+}
diff --git a/src/Frontend/src/main.ts b/src/Frontend/src/main.ts
index cd549b1d06..bfab6b6f8c 100644
--- a/src/Frontend/src/main.ts
+++ b/src/Frontend/src/main.ts
@@ -10,7 +10,8 @@ async function conditionallyEnableMocking() {
return;
}
- const { worker } = await import("@/../test/mocks/browser");
+ const { loadScenario } = await import("@/../test/mocks/scenarios");
+ const { worker } = await loadScenario();
// `worker.start()` returns a Promise that resolves
// once the Service Worker is up and ready to intercept requests.
diff --git a/src/Frontend/src/mount.ts b/src/Frontend/src/mount.ts
index d8eab93497..d9f1223d58 100644
--- a/src/Frontend/src/mount.ts
+++ b/src/Frontend/src/mount.ts
@@ -1,6 +1,6 @@
import { createApp } from "vue";
import type { Router } from "vue-router";
-import App from "./App.vue";
+import AuthApp from "./AuthApp.vue";
import Toast, { type PluginOptions, POSITION } from "vue-toastification";
import VueTippy from "vue-tippy";
import { createPinia } from "pinia";
@@ -22,7 +22,7 @@ export function mount({ router }: { router: Router }) {
next();
});
- const app = createApp(App);
+ const app = createApp(AuthApp);
app.use(router).use(Toast, toastOptions).use(SimpleTypeahead).use(createPinia()).use(VueTippy);
app.mount(`#app`);
diff --git a/src/Frontend/src/router/config.ts b/src/Frontend/src/router/config.ts
index 63da573eff..9e4f579db0 100644
--- a/src/Frontend/src/router/config.ts
+++ b/src/Frontend/src/router/config.ts
@@ -9,6 +9,7 @@ import CustomChecksView from "@/views/CustomChecksView.vue";
import HeartbeatsView from "@/views/HeartbeatsView.vue";
import ThroughputReportView from "@/views/ThroughputReportView.vue";
import AuditView from "@/views/AuditView.vue";
+import LoggedOutView from "@/views/LoggedOutView.vue";
export interface RouteItem {
path: string;
@@ -17,9 +18,16 @@ export interface RouteItem {
title: string;
component?: RouteComponent | (() => Promise);
children?: RouteItem[];
+ allowAnonymous?: boolean;
}
const config: RouteItem[] = [
+ {
+ path: routeLinks.loggedOut,
+ component: LoggedOutView,
+ title: "Signed Out",
+ allowAnonymous: true,
+ },
{
path: routeLinks.dashboard,
component: DashboardView,
diff --git a/src/Frontend/src/router/index.ts b/src/Frontend/src/router/index.ts
index 9384b05cb4..1a96454c44 100644
--- a/src/Frontend/src/router/index.ts
+++ b/src/Frontend/src/router/index.ts
@@ -1,8 +1,11 @@
import { createRouter, createWebHashHistory, type RouteRecordRaw, RouteRecordSingleViewWithChildren } from "vue-router";
import config, { RouteItem } from "./config";
-function meta(item: { title: string }) {
- return { title: `${item.title} • ServicePulse` };
+function meta(item: RouteItem) {
+ return {
+ title: `${item.title} • ServicePulse`,
+ allowAnonymous: item.allowAnonymous ?? false,
+ };
}
function addChildren(parent: RouteRecordSingleViewWithChildren, item: RouteItem) {
diff --git a/src/Frontend/src/router/routeLinks.ts b/src/Frontend/src/router/routeLinks.ts
index 9cb63ed9a6..64ee77620c 100644
--- a/src/Frontend/src/router/routeLinks.ts
+++ b/src/Frontend/src/router/routeLinks.ts
@@ -107,6 +107,7 @@ const routeLinks = {
messages: messagesLinks("/messages"),
configuration: configurationLinks("/configuration"),
throughput: throughputLinks("/usage"),
+ loggedOut: "/logged-out",
};
export default routeLinks;
diff --git a/src/Frontend/src/stores/AuthStore.ts b/src/Frontend/src/stores/AuthStore.ts
new file mode 100644
index 0000000000..8ee3062751
--- /dev/null
+++ b/src/Frontend/src/stores/AuthStore.ts
@@ -0,0 +1,124 @@
+import { acceptHMRUpdate, defineStore } from "pinia";
+import { ref } from "vue";
+import type { AuthConfig } from "@/types/auth";
+import { WebStorageStateStore } from "oidc-client-ts";
+import routeLinks from "@/router/routeLinks";
+import serviceControlClient from "@/components/serviceControlClient";
+
+interface AuthConfigResponse {
+ enabled: boolean;
+ client_id: string;
+ authority: string;
+ api_scopes: string;
+ audience: string;
+}
+
+export const useAuthStore = defineStore("auth", () => {
+ const token = ref(null);
+ const isAuthenticated = ref(false);
+ const isAuthenticating = ref(false);
+ const authError = ref(null);
+ const authConfig = ref(null);
+ const authEnabled = ref(false);
+ const loading = ref(true);
+
+ async function refresh() {
+ loading.value = true;
+ try {
+ const config = await getAuthConfig();
+ if (config) {
+ authEnabled.value = config.enabled;
+ authConfig.value = config.enabled ? transformToAuthConfig(config) : null;
+ }
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ async function getAuthConfig() {
+ try {
+ const [, data] = await serviceControlClient.fetchTypedFromServiceControl("authentication/configuration");
+ return data;
+ } catch (err) {
+ console.error("Error fetching auth configuration", err);
+ return null;
+ }
+ }
+
+ function transformToAuthConfig(config: AuthConfigResponse): AuthConfig {
+ const apiScope = JSON.parse(config.api_scopes).join(" ");
+ // Use hash-based URL for post-logout redirect since the app uses hash routing
+ const postLogoutRedirectUri = `${window.location.origin}${window.location.pathname}#${routeLinks.loggedOut}`;
+ return {
+ authority: config.authority,
+ client_id: config.client_id,
+ redirect_uri: window.location.origin,
+ post_logout_redirect_uri: postLogoutRedirectUri,
+ response_type: "code",
+ scope: `${apiScope} openid profile email offline_access`,
+ automaticSilentRenew: true,
+ loadUserInfo: false,
+ includeIdTokenInSilentRenew: true,
+ silent_redirect_uri: window.location.origin + "/silent-renew.html",
+ filterProtocolClaims: true,
+ userStore: new WebStorageStateStore({ store: window.sessionStorage }),
+ extraQueryParams: {
+ audience: config.audience,
+ },
+ };
+ }
+
+ function setToken(newToken: string | null) {
+ token.value = newToken;
+ isAuthenticated.value = !!newToken;
+
+ if (newToken) {
+ sessionStorage.setItem("auth_token", newToken);
+ } else {
+ sessionStorage.removeItem("auth_token");
+ }
+ }
+
+ function clearToken() {
+ setToken(null);
+ authError.value = null;
+ }
+
+ function loadTokenFromStorage() {
+ const storedToken = sessionStorage.getItem("auth_token");
+ if (storedToken) {
+ token.value = storedToken;
+ isAuthenticated.value = true;
+ }
+ }
+
+ function setAuthenticating(value: boolean) {
+ isAuthenticating.value = value;
+ }
+
+ function setAuthError(error: string | null) {
+ authError.value = error;
+ }
+
+ return {
+ token,
+ isAuthenticated,
+ isAuthenticating,
+ authError,
+ authConfig,
+ authEnabled,
+ loading,
+ refresh,
+ setToken,
+ clearToken,
+ loadTokenFromStorage,
+ setAuthenticating,
+ setAuthError,
+ };
+});
+
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useAuthStore, import.meta.hot));
+}
+
+export type AuthStore = ReturnType;
diff --git a/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts b/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts
index 56ab03151d..1372437f35 100644
--- a/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts
+++ b/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts
@@ -6,6 +6,7 @@ import { acceptHMRUpdate, defineStore } from "pinia";
import { computed, reactive } from "vue";
import serviceControlClient from "@/components/serviceControlClient";
import monitoringClient from "@/components/monitoring/monitoringClient";
+import { authFetch } from "@/composables/useAuthenticatedFetch";
export const useEnvironmentAndVersionsStore = defineStore("EnvironmentAndVersionsStore", () => {
const environment = reactive({
@@ -104,9 +105,10 @@ export const useEnvironmentAndVersionsStore = defineStore("EnvironmentAndVersion
};
});
-async function getData(url: string) {
+async function getData(url: string, authenticated = false) {
try {
- const response = await fetch(url);
+ // eslint-disable-next-line local/no-raw-fetch
+ const response = await (authenticated ? authFetch(url) : fetch(url)); // this needs to be an unauthenticated call
return (await response.json()) as unknown as Release[];
} catch (e) {
console.log(e);
@@ -124,8 +126,8 @@ async function useServiceProductUrls() {
const spURL = "https://platformupdate.particular.net/servicepulse.txt";
const scURL = "https://platformupdate.particular.net/servicecontrol.txt";
- const servicePulse = getData(spURL);
- const serviceControl = getData(scURL);
+ const servicePulse = getData(spURL, false);
+ const serviceControl = getData(scURL, false);
const [sp, sc] = await Promise.all([servicePulse, serviceControl]);
const latestSP = sp[0];
diff --git a/src/Frontend/src/types/auth.ts b/src/Frontend/src/types/auth.ts
new file mode 100644
index 0000000000..cdcafba80f
--- /dev/null
+++ b/src/Frontend/src/types/auth.ts
@@ -0,0 +1,7 @@
+import type { UserManagerSettings } from "oidc-client-ts";
+
+/**
+ * Extended OIDC configuration using 'oidc-client-ts' package
+ * This provides type-safe configuration for any OIDC-compliant identity provider
+ */
+export type AuthConfig = UserManagerSettings;
diff --git a/src/Frontend/src/views/LoggedOutView.vue b/src/Frontend/src/views/LoggedOutView.vue
new file mode 100644
index 0000000000..7328039675
--- /dev/null
+++ b/src/Frontend/src/views/LoggedOutView.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
You have been signed out
+
You have successfully signed out of ServicePulse.
+
+
+
+
Authentication is disabled
+
Authentication is currently disabled in ServicePulse
+
+
+
+
+
diff --git a/src/Frontend/test/mocks/browser.ts b/src/Frontend/test/mocks/browser.ts
index 5b192293d7..4b3d5a760e 100644
--- a/src/Frontend/test/mocks/browser.ts
+++ b/src/Frontend/test/mocks/browser.ts
@@ -22,7 +22,8 @@ const makeDriver = (): Driver => ({
const driver = makeDriver();
-(async () => {
+// Export a promise that resolves when all mock handlers are registered
+export const setupComplete = (async () => {
await driver.setUp(precondition.serviceControlWithMonitoring);
//override the default mocked endpoints with a custom list
await driver.setUp(precondition.hasCustomChecks(3, 2));
diff --git a/src/Frontend/test/mocks/oidc-client-mock.ts b/src/Frontend/test/mocks/oidc-client-mock.ts
new file mode 100644
index 0000000000..cdcb3ca875
--- /dev/null
+++ b/src/Frontend/test/mocks/oidc-client-mock.ts
@@ -0,0 +1,124 @@
+/**
+ * Mock for oidc-client-ts library
+ *
+ * This mock allows testing the full authentication flow without
+ * requiring a real identity provider or browser redirects.
+ *
+ * Usage in test file (must be at the top, before other imports):
+ *
+ * ```typescript
+ * import { vi } from "vitest";
+ * import { createOidcMock } from "../../mocks/oidc-client-mock";
+ *
+ * vi.mock("oidc-client-ts", () => createOidcMock());
+ *
+ * // ... rest of imports and tests
+ * ```
+ */
+import { vi } from "vitest";
+
+export interface MockUser {
+ access_token: string;
+ expired: boolean;
+ profile: {
+ name: string;
+ email: string;
+ sub: string;
+ };
+}
+
+export const defaultMockUser: MockUser = {
+ access_token: "mock-access-token-for-testing",
+ expired: false,
+ profile: {
+ name: "Test User",
+ email: "test.user@example.com",
+ sub: "user-123",
+ },
+};
+
+/**
+ * Creates the oidc-client-ts mock module with an authenticated user.
+ * Use with vi.mock("oidc-client-ts", () => createOidcMock())
+ *
+ * @param user - The mock user to return from getUser(), or null for unauthenticated
+ */
+export function createOidcMock(user: MockUser | null = defaultMockUser) {
+ return {
+ UserManager: class MockUserManager {
+ getUser = vi.fn().mockResolvedValue(user);
+ signinRedirect = vi.fn().mockResolvedValue(undefined);
+ signinCallback = vi.fn().mockResolvedValue(user);
+ signinSilent = vi.fn().mockResolvedValue(user);
+ signoutRedirect = vi.fn().mockResolvedValue(undefined);
+ removeUser = vi.fn().mockResolvedValue(undefined);
+ events = {
+ addUserLoaded: vi.fn(),
+ addUserUnloaded: vi.fn(),
+ addAccessTokenExpiring: vi.fn(),
+ addAccessTokenExpired: vi.fn(),
+ addSilentRenewError: vi.fn(),
+ };
+ },
+ WebStorageStateStore: class MockWebStorageStateStore {},
+ };
+}
+
+/**
+ * Creates a mock for unauthenticated state (will trigger login redirect)
+ */
+export function createOidcMockUnauthenticated() {
+ return createOidcMock(null);
+}
+
+/**
+ * Creates a mock where signinSilent fails (simulates IdP session expired).
+ * Initial authentication works, but token renewal fails.
+ */
+export function createOidcMockWithFailingSilentRenew(user: MockUser = defaultMockUser) {
+ return {
+ UserManager: class MockUserManager {
+ getUser = vi.fn().mockResolvedValue(user);
+ signinRedirect = vi.fn().mockResolvedValue(undefined);
+ signinCallback = vi.fn().mockResolvedValue(user);
+ // signinSilent fails - simulating IdP session expired
+ signinSilent = vi.fn().mockRejectedValue(new Error("Silent renewal failed: IdP session expired"));
+ signoutRedirect = vi.fn().mockResolvedValue(undefined);
+ removeUser = vi.fn().mockResolvedValue(undefined);
+ events = {
+ addUserLoaded: vi.fn(),
+ addUserUnloaded: vi.fn(),
+ addAccessTokenExpiring: vi.fn(),
+ addAccessTokenExpired: vi.fn(),
+ addSilentRenewError: vi.fn(),
+ };
+ },
+ WebStorageStateStore: class MockWebStorageStateStore {},
+ };
+}
+
+/**
+ * Creates a mock where signinCallback fails (simulates invalid redirect URI).
+ * This happens when the identity provider rejects the OAuth callback.
+ */
+export function createOidcMockWithInvalidRedirectUri() {
+ return {
+ UserManager: class MockUserManager {
+ getUser = vi.fn().mockResolvedValue(null);
+ signinRedirect = vi.fn().mockResolvedValue(undefined);
+ // signinCallback fails - simulating IdP rejecting the redirect URI
+ signinCallback = vi.fn().mockRejectedValue(new Error("Invalid redirect_uri: The redirect URI in the request does not match the configured redirect URIs"));
+ signinSilent = vi.fn().mockRejectedValue(new Error("No user session"));
+ signoutRedirect = vi.fn().mockResolvedValue(undefined);
+ removeUser = vi.fn().mockResolvedValue(undefined);
+ events = {
+ addUserLoaded: vi.fn(),
+ addUserUnloaded: vi.fn(),
+ addAccessTokenExpiring: vi.fn(),
+ addAccessTokenExpired: vi.fn(),
+ addSilentRenewError: vi.fn(),
+ };
+ },
+ WebStorageStateStore: class MockWebStorageStateStore {},
+ };
+}
diff --git a/src/Frontend/test/mocks/scenarios/authentication/auth-authenticated.ts b/src/Frontend/test/mocks/scenarios/authentication/auth-authenticated.ts
new file mode 100644
index 0000000000..4688728485
--- /dev/null
+++ b/src/Frontend/test/mocks/scenarios/authentication/auth-authenticated.ts
@@ -0,0 +1,52 @@
+/**
+ * Scenario 3: Authenticated User State
+ *
+ * This scenario mocks a user who has completed the OIDC login flow.
+ * The app loads with an authenticated session already established.
+ *
+ * Note: This bypasses the actual OIDC redirect flow - it simulates
+ * the state after a successful login.
+ *
+ * Usage:
+ * set VITE_MOCK_SCENARIO=auth-authenticated
+ * npm run dev:mocks
+ *
+ * Test:
+ * 1. Open browser to the dev server URL
+ * 2. Dashboard should load directly (no login redirect)
+ * 3. User profile menu should appear in header
+ */
+import { setupWorker } from "msw/browser";
+import { Driver } from "../../../driver";
+import { makeMockEndpoint, makeMockEndpointDynamic } from "../../../mock-endpoint";
+import * as precondition from "../../../preconditions";
+
+export const worker = setupWorker();
+const mockEndpoint = makeMockEndpoint({ mockServer: worker });
+const mockEndpointDynamic = makeMockEndpointDynamic({ mockServer: worker });
+
+const makeDriver = (): Driver => ({
+ goTo() {
+ throw new Error("Not implemented");
+ },
+ mockEndpoint,
+ mockEndpointDynamic,
+ setUp(factory) {
+ return factory({ driver: this });
+ },
+ disposeApp() {
+ throw new Error("Not implemented");
+ },
+});
+
+const driver = makeDriver();
+
+// Export a promise that resolves when all mock handlers are registered
+export const setupComplete = (async () => {
+ // Scenario 3: Authenticated user state (shared precondition)
+ await driver.setUp(precondition.scenarioAuthenticatedUser);
+
+ // Sample data for testing
+ await driver.setUp(precondition.hasCustomChecks(3, 2));
+ await driver.setUp(precondition.monitoredEndpointsNamed(["Sales.OrderProcessor", "Sales.PaymentHandler", "Shipping.DeliveryService"]));
+})();
diff --git a/src/Frontend/test/mocks/scenarios/authentication/auth-disabled.ts b/src/Frontend/test/mocks/scenarios/authentication/auth-disabled.ts
new file mode 100644
index 0000000000..59f1d87541
--- /dev/null
+++ b/src/Frontend/test/mocks/scenarios/authentication/auth-disabled.ts
@@ -0,0 +1,49 @@
+/**
+ * Scenario 1: Authentication Disabled (Default)
+ *
+ * This scenario mocks ServiceControl with authentication disabled.
+ * ServicePulse loads directly without any login prompt.
+ *
+ * Usage:
+ * set VITE_MOCK_SCENARIO=auth-disabled
+ * npm run dev:mocks
+ *
+ * Test:
+ * 1. Open browser to the dev server URL
+ * 2. ServicePulse should load directly without login
+ * 3. User profile menu should NOT appear in header
+ */
+import { setupWorker } from "msw/browser";
+import { Driver } from "../../../driver";
+import { makeMockEndpoint, makeMockEndpointDynamic } from "../../../mock-endpoint";
+import * as precondition from "../../../preconditions";
+
+export const worker = setupWorker();
+const mockEndpoint = makeMockEndpoint({ mockServer: worker });
+const mockEndpointDynamic = makeMockEndpointDynamic({ mockServer: worker });
+
+const makeDriver = (): Driver => ({
+ goTo() {
+ throw new Error("Not implemented");
+ },
+ mockEndpoint,
+ mockEndpointDynamic,
+ setUp(factory) {
+ return factory({ driver: this });
+ },
+ disposeApp() {
+ throw new Error("Not implemented");
+ },
+});
+
+const driver = makeDriver();
+
+// Export a promise that resolves when all mock handlers are registered
+export const setupComplete = (async () => {
+ // Scenario 1: Authentication disabled (shared precondition)
+ await driver.setUp(precondition.scenarioAuthDisabled);
+
+ // Sample data for testing
+ await driver.setUp(precondition.hasCustomChecks(3, 2));
+ await driver.setUp(precondition.monitoredEndpointsNamed(["Sales.OrderProcessor", "Sales.PaymentHandler", "Shipping.DeliveryService"]));
+})();
diff --git a/src/Frontend/test/mocks/scenarios/authentication/auth-enabled.ts b/src/Frontend/test/mocks/scenarios/authentication/auth-enabled.ts
new file mode 100644
index 0000000000..a85988f439
--- /dev/null
+++ b/src/Frontend/test/mocks/scenarios/authentication/auth-enabled.ts
@@ -0,0 +1,52 @@
+/**
+ * Scenario 2: Authentication Enabled
+ *
+ * This scenario mocks ServiceControl with authentication enabled.
+ * The auth configuration endpoint returns valid OIDC settings.
+ *
+ * Note: This scenario only mocks the configuration endpoint.
+ * The actual OIDC flow requires a real identity provider.
+ *
+ * Usage:
+ * set VITE_MOCK_SCENARIO=auth-enabled
+ * npm run dev:mocks
+ *
+ * Test:
+ * 1. Open browser to the dev server URL
+ * 2. ServicePulse should redirect to identity provider (will fail without real IdP)
+ * 3. Check Network tab for /api/authentication/configuration response
+ */
+import { setupWorker } from "msw/browser";
+import { Driver } from "../../../driver";
+import { makeMockEndpoint, makeMockEndpointDynamic } from "../../../mock-endpoint";
+import * as precondition from "../../../preconditions";
+
+export const worker = setupWorker();
+const mockEndpoint = makeMockEndpoint({ mockServer: worker });
+const mockEndpointDynamic = makeMockEndpointDynamic({ mockServer: worker });
+
+const makeDriver = (): Driver => ({
+ goTo() {
+ throw new Error("Not implemented");
+ },
+ mockEndpoint,
+ mockEndpointDynamic,
+ setUp(factory) {
+ return factory({ driver: this });
+ },
+ disposeApp() {
+ throw new Error("Not implemented");
+ },
+});
+
+const driver = makeDriver();
+
+// Export a promise that resolves when all mock handlers are registered
+export const setupComplete = (async () => {
+ // Scenario 2: Authentication enabled (shared precondition)
+ await driver.setUp(precondition.scenarioAuthEnabled);
+
+ // Sample data for testing
+ await driver.setUp(precondition.hasCustomChecks(3, 2));
+ await driver.setUp(precondition.monitoredEndpointsNamed(["Sales.OrderProcessor", "Sales.PaymentHandler", "Shipping.DeliveryService"]));
+})();
diff --git a/src/Frontend/test/mocks/scenarios/index.ts b/src/Frontend/test/mocks/scenarios/index.ts
new file mode 100644
index 0000000000..38d529e8dc
--- /dev/null
+++ b/src/Frontend/test/mocks/scenarios/index.ts
@@ -0,0 +1,47 @@
+/**
+ * Mock Scenarios Index
+ *
+ * This file dynamically loads the appropriate mock scenario based on
+ * the VITE_MOCK_SCENARIO environment variable.
+ *
+ * Usage:
+ * VITE_MOCK_SCENARIO=auth-disabled npm run dev:mocks
+ *
+ * Or add to package.json scripts:
+ * "dev:mocks:auth-disabled": "cross-env NODE_ENV=dev-mocks VITE_MOCK_SCENARIO=auth-disabled vite"
+ */
+
+type ScenarioModule = {
+ worker: import("msw/browser").SetupWorker;
+ setupComplete?: Promise;
+};
+
+const scenarios: Record Promise> = {
+ default: () => import("../browser"),
+ "auth-disabled": () => import("./authentication/auth-disabled"),
+ "auth-enabled": () => import("./authentication/auth-enabled"),
+ "auth-authenticated": () => import("./authentication/auth-authenticated"),
+};
+
+export async function loadScenario(): Promise {
+ // Trim to handle Windows CMD whitespace issues (e.g., "set VAR=value && cmd" includes trailing space)
+ const scenarioName = import.meta.env.VITE_MOCK_SCENARIO?.trim() || "default";
+ const loader = scenarios[scenarioName];
+
+ if (!loader) {
+ console.warn(`Unknown mock scenario: "${scenarioName}", falling back to default. Available: ${Object.keys(scenarios).join(", ")}`);
+ const module = await scenarios.default();
+ if (module.setupComplete) await module.setupComplete;
+ return module;
+ }
+
+ console.log(`Loading mock scenario: ${scenarioName}`);
+ const module = await loader();
+
+ // Wait for setup to complete before returning
+ if (module.setupComplete) {
+ await module.setupComplete;
+ }
+
+ return module;
+}
diff --git a/src/Frontend/test/preconditions/authentication.ts b/src/Frontend/test/preconditions/authentication.ts
new file mode 100644
index 0000000000..78afe42e3e
--- /dev/null
+++ b/src/Frontend/test/preconditions/authentication.ts
@@ -0,0 +1,192 @@
+import { SetupFactoryOptions } from "../driver";
+import * as precondition from ".";
+
+/**
+ * Authentication configuration response from ServiceControl
+ * Endpoint: GET /api/authentication/configuration
+ */
+export interface AuthConfigResponse {
+ enabled: boolean;
+ client_id: string;
+ authority: string;
+ api_scopes: string;
+ audience: string;
+}
+
+/**
+ * Default disabled auth configuration
+ * Used when authentication is not configured in ServiceControl
+ */
+export const authDisabledConfig: AuthConfigResponse = {
+ enabled: false,
+ client_id: "",
+ authority: "",
+ api_scopes: "[]",
+ audience: "",
+};
+
+/**
+ * Example enabled auth configuration for testing
+ * Uses placeholder values - replace with real IdP config for integration tests
+ */
+export const authEnabledConfig: AuthConfigResponse = {
+ enabled: true,
+ client_id: "servicepulse-test",
+ authority: "https://login.microsoftonline.com/test-tenant-id/v2.0",
+ api_scopes: '["api://servicecontrol/access_as_user"]',
+ audience: "api://servicecontrol",
+};
+
+/**
+ * Scenario 1: Authentication Disabled (Default)
+ * ServiceControl returns auth as disabled, no login required
+ */
+export const hasAuthenticationDisabled =
+ () =>
+ ({ driver }: SetupFactoryOptions) => {
+ const serviceControlInstanceUrl = window.defaultConfig.service_control_url;
+ driver.mockEndpoint(`${serviceControlInstanceUrl}authentication/configuration`, {
+ body: authDisabledConfig,
+ });
+ return authDisabledConfig;
+ };
+
+/**
+ * Authentication enabled with custom configuration
+ * @param config - Custom auth configuration to return
+ */
+export const hasAuthenticationEnabled =
+ (config: Partial = {}) =>
+ ({ driver }: SetupFactoryOptions) => {
+ const fullConfig: AuthConfigResponse = {
+ ...authEnabledConfig,
+ ...config,
+ };
+ const serviceControlInstanceUrl = window.defaultConfig.service_control_url;
+ driver.mockEndpoint(`${serviceControlInstanceUrl}authentication/configuration`, {
+ body: fullConfig,
+ });
+ return fullConfig;
+ };
+
+/**
+ * Authentication endpoint returns an error (ServiceControl unavailable)
+ */
+export const hasAuthenticationError =
+ (status = 500) =>
+ ({ driver }: SetupFactoryOptions) => {
+ const serviceControlInstanceUrl = window.defaultConfig.service_control_url;
+ driver.mockEndpoint(`${serviceControlInstanceUrl}authentication/configuration`, {
+ status,
+ body: { error: "ServiceControl unavailable" },
+ });
+ };
+
+/**
+ * Scenario 1: Complete setup for authentication disabled
+ * Includes ServiceControl with monitoring and auth disabled config
+ * Use this for both manual mock scenarios and automated tests
+ */
+export const scenarioAuthDisabled = async ({ driver }: SetupFactoryOptions) => {
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(hasAuthenticationDisabled());
+ return authDisabledConfig;
+};
+
+/**
+ * Scenario 2: Complete setup for authentication enabled
+ * Includes ServiceControl with monitoring and auth enabled config
+ * Use this for both manual mock scenarios and automated tests
+ */
+export const scenarioAuthEnabled = async ({ driver }: SetupFactoryOptions) => {
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(hasAuthenticationEnabled());
+ return authEnabledConfig;
+};
+
+/**
+ * Mock user profile for authenticated state testing
+ */
+export const mockAuthenticatedUser = {
+ name: "Test User",
+ email: "test.user@example.com",
+ sub: "user-123",
+};
+
+/**
+ * Mock OIDC discovery document for browser-based testing.
+ * This allows oidc-client-ts to initialize without a real IdP.
+ */
+export const hasOidcDiscoveryMock =
+ () =>
+ ({ driver }: SetupFactoryOptions) => {
+ const authority = authEnabledConfig.authority;
+
+ // Mock the OIDC discovery endpoint
+ driver.mockEndpoint(`${authority}/.well-known/openid-configuration`, {
+ body: {
+ issuer: authority,
+ authorization_endpoint: `${authority}/authorize`,
+ token_endpoint: `${authority}/token`,
+ userinfo_endpoint: `${authority}/userinfo`,
+ end_session_endpoint: `${authority}/logout`,
+ jwks_uri: `${authority}/keys`,
+ response_types_supported: ["code", "id_token", "token"],
+ subject_types_supported: ["public"],
+ id_token_signing_alg_values_supported: ["RS256"],
+ scopes_supported: ["openid", "profile", "email", "offline_access"],
+ },
+ });
+ };
+
+/**
+ * Creates a mock OIDC user object in the format oidc-client-ts expects.
+ * This is stored in sessionStorage and retrieved by UserManager.getUser()
+ */
+function createOidcUserObject(token: string, profile: typeof mockAuthenticatedUser) {
+ const now = Math.floor(Date.now() / 1000);
+ const expiresIn = 3600; // 1 hour
+
+ return {
+ access_token: token,
+ token_type: "Bearer",
+ expires_at: now + expiresIn,
+ expired: false,
+ scope: "openid profile email offline_access",
+ profile: {
+ sub: profile.sub,
+ name: profile.name,
+ email: profile.email,
+ iss: authEnabledConfig.authority,
+ aud: authEnabledConfig.client_id,
+ exp: now + expiresIn,
+ iat: now,
+ },
+ };
+}
+
+/**
+ * Scenario 3: Authenticated user state
+ * Simulates a user who has completed the OIDC login flow.
+ * Pre-populates oidc-client-ts storage with a mock authenticated user.
+ *
+ * Works for both browser-based manual testing and automated tests.
+ */
+export const scenarioAuthenticatedUser = async ({ driver }: SetupFactoryOptions) => {
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(hasAuthenticationEnabled());
+ await driver.setUp(hasOidcDiscoveryMock());
+
+ // Create mock token and user
+ const mockToken = "mock-access-token-for-testing";
+ const oidcUser = createOidcUserObject(mockToken, mockAuthenticatedUser);
+
+ // Store in the format oidc-client-ts expects: oidc.user:{authority}:{client_id}
+ const storageKey = `oidc.user:${authEnabledConfig.authority}:${authEnabledConfig.client_id}`;
+ sessionStorage.setItem(storageKey, JSON.stringify(oidcUser));
+
+ // Also set the auth_token for the AuthStore
+ sessionStorage.setItem("auth_token", mockToken);
+
+ return { authConfig: authEnabledConfig, user: mockAuthenticatedUser, token: mockToken };
+};
diff --git a/src/Frontend/test/preconditions/index.ts b/src/Frontend/test/preconditions/index.ts
index b2a0bcc10c..ca2536b1dd 100644
--- a/src/Frontend/test/preconditions/index.ts
+++ b/src/Frontend/test/preconditions/index.ts
@@ -21,3 +21,4 @@ export { hasLicensingSettingTest } from "../preconditions/hasLicensingSettingTes
export { hasLicensingEndpoints } from "../preconditions/hasLicensingEndpoints";
export { hasEndpointSettings } from "./hasEndpointSettings";
export * from "./configuration";
+export * from "./authentication";
diff --git a/src/Frontend/test/preconditions/serviceControlWithMonitoring.ts b/src/Frontend/test/preconditions/serviceControlWithMonitoring.ts
index dfae768ace..7a1c46b349 100644
--- a/src/Frontend/test/preconditions/serviceControlWithMonitoring.ts
+++ b/src/Frontend/test/preconditions/serviceControlWithMonitoring.ts
@@ -4,6 +4,9 @@ import { SetupFactoryOptions } from "../driver";
export const serviceControlWithMonitoring = async ({ driver }: SetupFactoryOptions) => {
//Service control requests minimum setup. Todo: encapsulate for reuse.
+ //http://localhost:33333/api/authentication/configuration - auth disabled by default
+ await driver.setUp(precondition.hasAuthenticationDisabled());
+
//http://localhost:33333/api/license
await driver.setUp(precondition.hasActiveLicense);
diff --git a/src/Frontend/test/specs/authentication/auth-authenticated.spec.ts b/src/Frontend/test/specs/authentication/auth-authenticated.spec.ts
new file mode 100644
index 0000000000..a85274b163
--- /dev/null
+++ b/src/Frontend/test/specs/authentication/auth-authenticated.spec.ts
@@ -0,0 +1,59 @@
+import { vi, expect } from "vitest";
+import { createOidcMock, defaultMockUser } from "../../mocks/oidc-client-mock";
+
+// Mock oidc-client-ts BEFORE any imports that use it
+vi.mock("oidc-client-ts", () => createOidcMock());
+
+import { test, describe } from "../../drivers/vitest/driver";
+import * as precondition from "../../preconditions";
+import { waitFor, screen } from "@testing-library/vue";
+
+describe("FEATURE: Authenticated User Access", () => {
+ describe("RULE: Authenticated users should see the dashboard", () => {
+ test("EXAMPLE: Dashboard loads with authenticated user", async ({ driver }) => {
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationEnabled());
+
+ await driver.goTo("/dashboard");
+
+ await waitFor(() => {
+ expect(screen.getByText(/Dashboard/i)).toBeInTheDocument();
+ });
+
+ // Verify auth token was set by the mocked UserManager
+ const authToken = sessionStorage.getItem("auth_token");
+ expect(authToken).toBe(defaultMockUser.access_token);
+ });
+ });
+
+ describe("RULE: Authenticated requests should include Bearer token", () => {
+ test("EXAMPLE: API requests include Authorization header", async ({ driver }) => {
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationEnabled());
+
+ // Track Authorization headers from API requests
+ const capturedHeaders: string[] = [];
+ const serviceControlUrl = window.defaultConfig.service_control_url;
+
+ driver.mockEndpointDynamic(`${serviceControlUrl}endpoints`, "get", (_url, _params, request) => {
+ const authHeader = request.headers.get("Authorization");
+ if (authHeader) {
+ capturedHeaders.push(authHeader);
+ }
+ return Promise.resolve({ body: [] });
+ });
+
+ await driver.goTo("/dashboard");
+
+ await waitFor(() => {
+ expect(screen.getByText(/Dashboard/i)).toBeInTheDocument();
+ });
+
+ // Verify at least one request included the Bearer token
+ await waitFor(() => {
+ expect(capturedHeaders.length).toBeGreaterThan(0);
+ expect(capturedHeaders[0]).toBe(`Bearer ${defaultMockUser.access_token}`);
+ });
+ });
+ });
+});
diff --git a/src/Frontend/test/specs/authentication/auth-callback-error.spec.ts b/src/Frontend/test/specs/authentication/auth-callback-error.spec.ts
new file mode 100644
index 0000000000..218a4e1d46
--- /dev/null
+++ b/src/Frontend/test/specs/authentication/auth-callback-error.spec.ts
@@ -0,0 +1,152 @@
+import { vi, expect, beforeEach } from "vitest";
+import { createOidcMockUnauthenticated } from "../../mocks/oidc-client-mock";
+
+// Mock oidc-client-ts with unauthenticated state
+vi.mock("oidc-client-ts", () => createOidcMockUnauthenticated());
+
+import { test, describe } from "../../drivers/vitest/driver";
+import * as precondition from "../../preconditions";
+import { waitFor } from "@testing-library/vue";
+import { useAuthStore } from "@/stores/AuthStore";
+
+describe("FEATURE: OAuth Callback Error Handling (Scenario 16)", () => {
+ describe("RULE: OAuth errors should be captured and displayed to the user", () => {
+ // Store the original location
+ let originalLocation: Location;
+
+ beforeEach(() => {
+ // Save original location
+ originalLocation = window.location;
+ });
+
+ test("EXAMPLE: access_denied error sets auth error state", async ({ driver }) => {
+ // Mock window.location.search to include OAuth error parameters
+ // This simulates the IdP redirecting back with an error
+ const mockSearch = "?error=access_denied&error_description=User%20cancelled%20the%20login";
+
+ // Create a mock location object
+ const mockLocation = {
+ ...originalLocation,
+ search: mockSearch,
+ hash: "#/dashboard",
+ href: `http://localhost:5173${mockSearch}#/dashboard`,
+ };
+
+ // Replace window.location
+ Object.defineProperty(window, "location", {
+ value: mockLocation,
+ writable: true,
+ configurable: true,
+ });
+
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationEnabled());
+
+ await driver.goTo("/dashboard");
+
+ const authStore = useAuthStore();
+
+ await waitFor(() => {
+ // Auth error should be set from the URL parameters
+ expect(authStore.authError).toBeTruthy();
+ });
+
+ // Verify the error message contains the description
+ expect(authStore.authError).toContain("cancelled");
+
+ // User should not be authenticated
+ expect(authStore.isAuthenticated).toBe(false);
+
+ // Restore original location
+ Object.defineProperty(window, "location", {
+ value: originalLocation,
+ writable: true,
+ configurable: true,
+ });
+ });
+
+ test("EXAMPLE: invalid_request error sets auth error state", async ({ driver }) => {
+ // Simulate invalid_request error (e.g., missing required parameter)
+ const mockSearch = "?error=invalid_request&error_description=Missing%20required%20parameter";
+
+ const mockLocation = {
+ ...originalLocation,
+ search: mockSearch,
+ hash: "#/dashboard",
+ href: `http://localhost:5173${mockSearch}#/dashboard`,
+ };
+
+ Object.defineProperty(window, "location", {
+ value: mockLocation,
+ writable: true,
+ configurable: true,
+ });
+
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationEnabled());
+
+ await driver.goTo("/dashboard");
+
+ const authStore = useAuthStore();
+
+ await waitFor(() => {
+ expect(authStore.authError).toBeTruthy();
+ });
+
+ // Verify the error captures the description
+ expect(authStore.authError).toContain("Missing");
+
+ // User should not be authenticated
+ expect(authStore.isAuthenticated).toBe(false);
+
+ // Restore original location
+ Object.defineProperty(window, "location", {
+ value: originalLocation,
+ writable: true,
+ configurable: true,
+ });
+ });
+
+ test("EXAMPLE: error without description uses error code", async ({ driver }) => {
+ // Simulate error without description
+ const mockSearch = "?error=server_error";
+
+ const mockLocation = {
+ ...originalLocation,
+ search: mockSearch,
+ hash: "#/dashboard",
+ href: `http://localhost:5173${mockSearch}#/dashboard`,
+ };
+
+ Object.defineProperty(window, "location", {
+ value: mockLocation,
+ writable: true,
+ configurable: true,
+ });
+
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationEnabled());
+
+ await driver.goTo("/dashboard");
+
+ const authStore = useAuthStore();
+
+ await waitFor(() => {
+ expect(authStore.authError).toBeTruthy();
+ });
+
+ // When no description, the error code should be used
+ expect(authStore.authError).toBe("server_error");
+
+ // User should not be authenticated
+ expect(authStore.isAuthenticated).toBe(false);
+
+ // Restore original location
+ Object.defineProperty(window, "location", {
+ value: originalLocation,
+ writable: true,
+ configurable: true,
+ });
+ });
+ });
+});
diff --git a/src/Frontend/test/specs/authentication/auth-config-unavailable.spec.ts b/src/Frontend/test/specs/authentication/auth-config-unavailable.spec.ts
new file mode 100644
index 0000000000..e4f82113c7
--- /dev/null
+++ b/src/Frontend/test/specs/authentication/auth-config-unavailable.spec.ts
@@ -0,0 +1,88 @@
+import { vi, expect } from "vitest";
+import { createOidcMock } from "../../mocks/oidc-client-mock";
+
+// Mock oidc-client-ts
+vi.mock("oidc-client-ts", () => createOidcMock());
+
+import { test, describe } from "../../drivers/vitest/driver";
+import * as precondition from "../../preconditions";
+import { waitFor, screen } from "@testing-library/vue";
+import { useAuthStore } from "@/stores/AuthStore";
+
+describe("FEATURE: Auth Configuration Endpoint Unavailable (Scenario 15)", () => {
+ describe("RULE: App should handle ServiceControl unavailability gracefully", () => {
+ test("EXAMPLE: App continues to load when auth config endpoint returns error", async ({ driver }) => {
+ // Set up ServiceControl endpoints but auth config returns 500 error
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationError(500));
+
+ await driver.goTo("/dashboard");
+
+ const authStore = useAuthStore();
+
+ // App should not crash - should continue loading
+ await waitFor(() => {
+ // Loading should complete (not stuck)
+ expect(authStore.loading).toBe(false);
+ });
+
+ // Since auth config failed, auth should be treated as disabled
+ expect(authStore.authEnabled).toBe(false);
+
+ // Dashboard should still be accessible (graceful degradation)
+ await waitFor(() => {
+ expect(screen.getByText(/Dashboard/i)).toBeInTheDocument();
+ });
+ });
+
+ test("EXAMPLE: App continues to load when auth config endpoint returns 503", async ({ driver }) => {
+ // Simulate ServiceControl being temporarily unavailable (503)
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationError(503));
+
+ await driver.goTo("/dashboard");
+
+ const authStore = useAuthStore();
+
+ await waitFor(() => {
+ // Loading should complete
+ expect(authStore.loading).toBe(false);
+ // Auth should be treated as disabled when config unavailable
+ expect(authStore.authEnabled).toBe(false);
+ });
+
+ // App should not crash or show blank screen
+ await waitFor(() => {
+ expect(screen.getByText(/Dashboard/i)).toBeInTheDocument();
+ });
+ });
+
+ test("EXAMPLE: App continues to load when auth config endpoint times out (network error)", async ({ driver }) => {
+ // Set up basic ServiceControl endpoints
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+
+ // Mock auth endpoint to throw network error
+ const serviceControlUrl = window.defaultConfig.service_control_url;
+ driver.mockEndpointDynamic(`${serviceControlUrl}authentication/configuration`, "get", () => {
+ return Promise.reject(new Error("Network error: Connection refused"));
+ });
+
+ await driver.goTo("/dashboard");
+
+ const authStore = useAuthStore();
+
+ await waitFor(() => {
+ // Loading should complete despite network error
+ expect(authStore.loading).toBe(false);
+ });
+
+ // Auth should be disabled (graceful fallback)
+ expect(authStore.authEnabled).toBe(false);
+
+ // Dashboard should still render
+ await waitFor(() => {
+ expect(screen.getByText(/Dashboard/i)).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/src/Frontend/test/specs/authentication/auth-direct-access.spec.ts b/src/Frontend/test/specs/authentication/auth-direct-access.spec.ts
new file mode 100644
index 0000000000..538cce4e93
--- /dev/null
+++ b/src/Frontend/test/specs/authentication/auth-direct-access.spec.ts
@@ -0,0 +1,80 @@
+import { vi, expect } from "vitest";
+import { createOidcMock, defaultMockUser } from "../../mocks/oidc-client-mock";
+
+// Mock oidc-client-ts BEFORE any imports that use it
+vi.mock("oidc-client-ts", () => createOidcMock());
+
+import { test, describe } from "../../drivers/vitest/driver";
+import * as precondition from "../../preconditions";
+import { waitFor, screen } from "@testing-library/vue";
+
+describe("FEATURE: Direct ServiceControl Access (Scenario 13)", () => {
+ describe("RULE: Bearer tokens should be included when accessing ServiceControl directly", () => {
+ test("EXAMPLE: API requests to absolute URLs include Authorization header", async ({ driver }) => {
+ // Default configuration uses absolute URLs (reverse proxy disabled mode)
+ // service_control_url is "http://localhost:33333/api/" by default
+ const serviceControlUrl = window.defaultConfig.service_control_url;
+
+ // Verify we're using absolute URL (not relative YARP path)
+ expect(serviceControlUrl).toMatch(/^https?:\/\//);
+ expect(serviceControlUrl).not.toBe("/api/");
+
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationEnabled());
+
+ // Track Authorization headers from API requests
+ const capturedHeaders: string[] = [];
+ const capturedUrls: string[] = [];
+
+ // Mock endpoint at absolute URL (direct ServiceControl access)
+ driver.mockEndpointDynamic(`${serviceControlUrl}endpoints`, "get", (url, _params, request) => {
+ capturedUrls.push(url.toString());
+ const authHeader = request.headers.get("Authorization");
+ if (authHeader) {
+ capturedHeaders.push(authHeader);
+ }
+ return Promise.resolve({ body: [] });
+ });
+
+ await driver.goTo("/dashboard");
+
+ await waitFor(() => {
+ expect(screen.getByText(/Dashboard/i)).toBeInTheDocument();
+ });
+
+ // Verify requests went to absolute URL (direct access, not through YARP)
+ await waitFor(() => {
+ expect(capturedUrls.length).toBeGreaterThan(0);
+ // URL should be absolute (direct to ServiceControl)
+ expect(capturedUrls[0]).toMatch(/^https?:\/\/localhost:\d+/);
+ });
+
+ // Verify Authorization header was included
+ await waitFor(() => {
+ expect(capturedHeaders.length).toBeGreaterThan(0);
+ expect(capturedHeaders[0]).toBe(`Bearer ${defaultMockUser.access_token}`);
+ });
+ });
+
+ test("EXAMPLE: Service control URL is configured as absolute path in direct mode", async ({ driver }) => {
+ // Default configuration uses absolute URLs
+ const serviceControlUrl = window.defaultConfig.service_control_url;
+
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationEnabled());
+
+ await driver.goTo("/dashboard");
+
+ await waitFor(() => {
+ expect(screen.getByText(/Dashboard/i)).toBeInTheDocument();
+ });
+
+ // Verify the service control URL is configured as absolute (direct mode)
+ expect(serviceControlUrl).toMatch(/^https?:\/\//);
+ expect(serviceControlUrl).toContain("localhost");
+
+ // Verify auth token is set
+ expect(sessionStorage.getItem("auth_token")).toBe(defaultMockUser.access_token);
+ });
+ });
+});
diff --git a/src/Frontend/test/specs/authentication/auth-disabled.spec.ts b/src/Frontend/test/specs/authentication/auth-disabled.spec.ts
new file mode 100644
index 0000000000..111d963ed1
--- /dev/null
+++ b/src/Frontend/test/specs/authentication/auth-disabled.spec.ts
@@ -0,0 +1,33 @@
+import { test, describe } from "../../drivers/vitest/driver";
+import { expect } from "vitest";
+import * as precondition from "../../preconditions";
+import { waitFor, screen } from "@testing-library/vue";
+
+describe("FEATURE: Authentication Disabled (Scenario 1)", () => {
+ describe("RULE: ServicePulse should load without login when auth is disabled", () => {
+ test("EXAMPLE: Dashboard loads directly without authentication prompt", async ({ driver }) => {
+ // Uses shared precondition for consistency with manual mock scenario
+ await driver.setUp(precondition.scenarioAuthDisabled);
+
+ await driver.goTo("/dashboard");
+
+ await waitFor(() => {
+ // Dashboard should load without redirect to login
+ expect(screen.getByText(/Dashboard/i)).toBeInTheDocument();
+ });
+ });
+
+ test("EXAMPLE: User profile menu should not appear when auth is disabled", async ({ driver }) => {
+ await driver.setUp(precondition.scenarioAuthDisabled);
+
+ await driver.goTo("/dashboard");
+
+ await waitFor(() => {
+ expect(screen.getByText(/Dashboard/i)).toBeInTheDocument();
+ });
+
+ // User profile menu should not be present
+ expect(screen.queryByTestId("user-profile-menu")).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/Frontend/test/specs/authentication/auth-enabled.spec.ts b/src/Frontend/test/specs/authentication/auth-enabled.spec.ts
new file mode 100644
index 0000000000..505d9e70e8
--- /dev/null
+++ b/src/Frontend/test/specs/authentication/auth-enabled.spec.ts
@@ -0,0 +1,40 @@
+import { test, describe } from "../../drivers/vitest/driver";
+import { expect } from "vitest";
+import * as precondition from "../../preconditions";
+
+describe("FEATURE: Authentication Enabled (Scenario 2)", () => {
+ describe("RULE: Authentication configuration endpoint should return valid OIDC config", () => {
+ test("EXAMPLE: Auth config endpoint returns enabled with all required fields", async ({ driver }) => {
+ const authConfig = await driver.setUp(precondition.scenarioAuthEnabled);
+
+ // Verify all required fields are present
+ expect(authConfig.enabled).toBe(true);
+ expect(authConfig.client_id).toBeDefined();
+ expect(authConfig.client_id).not.toBe("");
+ expect(authConfig.authority).toBeDefined();
+ expect(authConfig.authority).toContain("https://");
+ expect(authConfig.api_scopes).toBeDefined();
+ expect(authConfig.audience).toBeDefined();
+
+ // Navigate to trigger app mount (required for cleanup)
+ await driver.goTo("/dashboard");
+ });
+
+ test("EXAMPLE: Auth config endpoint is accessible without authentication", async ({ driver }) => {
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationEnabled());
+
+ // Navigate to trigger app mount
+ await driver.goTo("/dashboard");
+
+ // The endpoint should be mocked and accessible
+ const serviceControlUrl = window.defaultConfig.service_control_url;
+ // eslint-disable-next-line local/no-raw-fetch
+ const response = await fetch(`${serviceControlUrl}authentication/configuration`);
+
+ expect(response.ok).toBe(true);
+ const data = await response.json();
+ expect(data.enabled).toBe(true);
+ });
+ });
+});
diff --git a/src/Frontend/test/specs/authentication/auth-logout.spec.ts b/src/Frontend/test/specs/authentication/auth-logout.spec.ts
new file mode 100644
index 0000000000..c8d8cb4431
--- /dev/null
+++ b/src/Frontend/test/specs/authentication/auth-logout.spec.ts
@@ -0,0 +1,54 @@
+import { vi, expect } from "vitest";
+import { createOidcMock, defaultMockUser } from "../../mocks/oidc-client-mock";
+
+// Mock oidc-client-ts BEFORE any imports that use it
+vi.mock("oidc-client-ts", () => createOidcMock());
+
+import { test, describe } from "../../drivers/vitest/driver";
+import * as precondition from "../../preconditions";
+import { waitFor, screen } from "@testing-library/vue";
+
+describe("FEATURE: Logout Flow (Scenario 8)", () => {
+ describe("RULE: Logged-out page should be accessible without authentication", () => {
+ test("EXAMPLE: Logged-out page displays sign-out confirmation", async ({ driver }) => {
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationEnabled());
+
+ // Navigate directly to logged-out page (anonymous route)
+ await driver.goTo("/logged-out");
+
+ await waitFor(() => {
+ // Verify the logged-out page content
+ expect(screen.getByText(/You have been signed out/i)).toBeInTheDocument();
+ expect(screen.getByText(/You have successfully signed out of ServicePulse/i)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /Sign in again/i })).toBeInTheDocument();
+ });
+ });
+
+ test("EXAMPLE: Logout clears auth token from session storage", async ({ driver }) => {
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationEnabled());
+
+ // First authenticate
+ await driver.goTo("/dashboard");
+
+ await waitFor(() => {
+ expect(screen.getByText(/Dashboard/i)).toBeInTheDocument();
+ });
+
+ // Verify token exists
+ expect(sessionStorage.getItem("auth_token")).toBe(defaultMockUser.access_token);
+
+ // Simulate logout by clearing token and navigating to logged-out page
+ sessionStorage.removeItem("auth_token");
+ await driver.goTo("/logged-out");
+
+ await waitFor(() => {
+ expect(screen.getByText(/You have been signed out/i)).toBeInTheDocument();
+ });
+
+ // Verify token is cleared
+ expect(sessionStorage.getItem("auth_token")).toBeNull();
+ });
+ });
+});
diff --git a/src/Frontend/test/specs/authentication/auth-renewal-failure.spec.ts b/src/Frontend/test/specs/authentication/auth-renewal-failure.spec.ts
new file mode 100644
index 0000000000..0d586877f8
--- /dev/null
+++ b/src/Frontend/test/specs/authentication/auth-renewal-failure.spec.ts
@@ -0,0 +1,28 @@
+import { vi, expect } from "vitest";
+import { createOidcMockWithFailingSilentRenew, defaultMockUser } from "../../mocks/oidc-client-mock";
+
+// Mock oidc-client-ts with signinSilent that fails
+vi.mock("oidc-client-ts", () => createOidcMockWithFailingSilentRenew());
+
+import { test, describe } from "../../drivers/vitest/driver";
+import * as precondition from "../../preconditions";
+import { waitFor, screen } from "@testing-library/vue";
+
+describe("FEATURE: Silent Renewal Failure", () => {
+ describe("RULE: App should handle silent renewal failures gracefully", () => {
+ test("EXAMPLE: User can still authenticate initially even with failing silent renewal", async ({ driver }) => {
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationEnabled());
+
+ await driver.goTo("/dashboard");
+
+ await waitFor(() => {
+ expect(screen.getByText(/Dashboard/i)).toBeInTheDocument();
+ });
+
+ // Initial authentication works (getUser returns a valid user)
+ // Even though signinSilent would fail, the initial auth succeeds
+ expect(sessionStorage.getItem("auth_token")).toBe(defaultMockUser.access_token);
+ });
+ });
+});
diff --git a/src/Frontend/test/specs/authentication/auth-session-storage.spec.ts b/src/Frontend/test/specs/authentication/auth-session-storage.spec.ts
new file mode 100644
index 0000000000..a064a8b0bf
--- /dev/null
+++ b/src/Frontend/test/specs/authentication/auth-session-storage.spec.ts
@@ -0,0 +1,71 @@
+import { vi, expect } from "vitest";
+import { createOidcMock, defaultMockUser } from "../../mocks/oidc-client-mock";
+
+// Mock oidc-client-ts BEFORE any imports that use it
+vi.mock("oidc-client-ts", () => createOidcMock());
+
+import { test, describe } from "../../drivers/vitest/driver";
+import * as precondition from "../../preconditions";
+import { waitFor, screen } from "@testing-library/vue";
+
+describe("FEATURE: Session Storage Behavior", () => {
+ describe("RULE: Sessions should be isolated per browser tab", () => {
+ test("EXAMPLE: Auth token is stored in sessionStorage (tab-specific, not shared)", async ({ driver }) => {
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationEnabled());
+
+ await driver.goTo("/dashboard");
+
+ await waitFor(() => {
+ expect(screen.getByText(/Dashboard/i)).toBeInTheDocument();
+ });
+
+ // Verify token is in sessionStorage (tab-specific)
+ const sessionToken = sessionStorage.getItem("auth_token");
+ expect(sessionToken).toBe(defaultMockUser.access_token);
+
+ // Verify token is NOT in localStorage (would be shared across tabs)
+ const localToken = localStorage.getItem("auth_token");
+ expect(localToken).toBeNull();
+ });
+ });
+
+ describe("RULE: Session should persist across navigation within the same tab", () => {
+ test("EXAMPLE: Auth token persists when navigating between pages", async ({ driver }) => {
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationEnabled());
+
+ // Initial navigation - triggers auth
+ await driver.goTo("/dashboard");
+
+ await waitFor(() => {
+ expect(screen.getByText(/Dashboard/i)).toBeInTheDocument();
+ });
+
+ // Verify initial auth token is set
+ const initialToken = sessionStorage.getItem("auth_token");
+ expect(initialToken).toBe(defaultMockUser.access_token);
+
+ // Navigate to a different route
+ await driver.goTo("/failed-messages/all");
+
+ // Wait for navigation to complete
+ await waitFor(() => {
+ // Verify token persists after navigation
+ const tokenAfterNav = sessionStorage.getItem("auth_token");
+ expect(tokenAfterNav).toBe(defaultMockUser.access_token);
+ });
+
+ // Navigate back to dashboard
+ await driver.goTo("/dashboard");
+
+ await waitFor(() => {
+ expect(screen.getByText(/Dashboard/i)).toBeInTheDocument();
+ });
+
+ // Verify token still persists
+ const tokenAfterReturn = sessionStorage.getItem("auth_token");
+ expect(tokenAfterReturn).toBe(defaultMockUser.access_token);
+ });
+ });
+});
diff --git a/src/Frontend/test/specs/authentication/auth-unauthenticated.spec.ts b/src/Frontend/test/specs/authentication/auth-unauthenticated.spec.ts
new file mode 100644
index 0000000000..5a1cd57150
--- /dev/null
+++ b/src/Frontend/test/specs/authentication/auth-unauthenticated.spec.ts
@@ -0,0 +1,46 @@
+import { vi, expect } from "vitest";
+import { createOidcMockUnauthenticated } from "../../mocks/oidc-client-mock";
+
+// Mock oidc-client-ts with unauthenticated state (no user)
+vi.mock("oidc-client-ts", () => createOidcMockUnauthenticated());
+
+import { test, describe } from "../../drivers/vitest/driver";
+import * as precondition from "../../preconditions";
+import { waitFor } from "@testing-library/vue";
+
+describe("FEATURE: Unauthenticated User Handling", () => {
+ describe("RULE: Unauthenticated users should not have access tokens", () => {
+ test("EXAMPLE: No auth token when user is not authenticated", async ({ driver }) => {
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationEnabled());
+
+ // Navigate to trigger app mount and auth flow
+ // The mocked UserManager returns null, so no token will be set
+ await driver.goTo("/dashboard");
+
+ // Since user is not authenticated, signinRedirect should be called
+ // and the app should not proceed to make authenticated API calls
+ await waitFor(() => {
+ const authToken = sessionStorage.getItem("auth_token");
+ expect(authToken).toBeNull();
+ });
+ });
+
+ test("EXAMPLE: signinRedirect is triggered when user has no session", async ({ driver }) => {
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationEnabled());
+
+ // Navigate to protected route without authentication
+ await driver.goTo("/dashboard");
+
+ // Since getUser returns null, the app should redirect to IdP
+ // Auth token should not be set
+ await waitFor(() => {
+ expect(sessionStorage.getItem("auth_token")).toBeNull();
+ });
+
+ // Note: In a real scenario, the IdP would handle the login.
+ // This test verifies the app correctly initiates the redirect flow.
+ });
+ });
+});
diff --git a/src/Frontend/test/specs/authentication/auth-yarp-proxy.spec.ts b/src/Frontend/test/specs/authentication/auth-yarp-proxy.spec.ts
new file mode 100644
index 0000000000..d3be11bfad
--- /dev/null
+++ b/src/Frontend/test/specs/authentication/auth-yarp-proxy.spec.ts
@@ -0,0 +1,84 @@
+import { vi, expect } from "vitest";
+import { createOidcMock, defaultMockUser } from "../../mocks/oidc-client-mock";
+
+// Mock oidc-client-ts BEFORE any imports that use it
+vi.mock("oidc-client-ts", () => createOidcMock());
+
+import { test, describe } from "../../drivers/vitest/driver";
+import * as precondition from "../../preconditions";
+import { waitFor, screen } from "@testing-library/vue";
+import monitoringClient from "@/components/monitoring/monitoringClient";
+import serviceControlClient from "@/components/serviceControlClient";
+
+describe("FEATURE: YARP Reverse Proxy Token Forwarding (Scenario 12)", () => {
+ describe("RULE: Bearer tokens should be forwarded through YARP proxy", () => {
+ test("EXAMPLE: API requests through relative URLs include Authorization header", async ({ driver }) => {
+ // Configure YARP mode with relative URLs
+ // When reverse proxy is enabled, service_control_url is "/api/" (relative path)
+ window.defaultConfig.service_control_url = "/api/";
+ window.defaultConfig.monitoring_urls = ["/monitoring-api/"];
+ serviceControlClient.resetUrl();
+ monitoringClient.resetUrl();
+
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationEnabled());
+
+ // Track Authorization headers from API requests through YARP
+ const capturedHeaders: string[] = [];
+ const capturedUrls: string[] = [];
+
+ // Mock endpoint at relative URL (YARP proxy path)
+ driver.mockEndpointDynamic("/api/endpoints", "get", (url, _params, request) => {
+ capturedUrls.push(url.toString());
+ const authHeader = request.headers.get("Authorization");
+ if (authHeader) {
+ capturedHeaders.push(authHeader);
+ }
+ return Promise.resolve({ body: [] });
+ });
+
+ await driver.goTo("/dashboard");
+
+ await waitFor(() => {
+ expect(screen.getByText(/Dashboard/i)).toBeInTheDocument();
+ });
+
+ // Verify requests went through the relative URL (YARP proxy)
+ await waitFor(() => {
+ expect(capturedUrls.length).toBeGreaterThan(0);
+ // URL should be relative (through YARP), not absolute
+ expect(capturedUrls[0]).toContain("/api/endpoints");
+ });
+
+ // Verify Authorization header was included (forwarded by YARP)
+ await waitFor(() => {
+ expect(capturedHeaders.length).toBeGreaterThan(0);
+ expect(capturedHeaders[0]).toBe(`Bearer ${defaultMockUser.access_token}`);
+ });
+ });
+
+ test("EXAMPLE: Service control URL is configured as relative path in YARP mode", async ({ driver }) => {
+ // Configure YARP mode with relative URLs
+ window.defaultConfig.service_control_url = "/api/";
+ window.defaultConfig.monitoring_urls = ["/monitoring-api/"];
+ serviceControlClient.resetUrl();
+ monitoringClient.resetUrl();
+
+ await driver.setUp(precondition.serviceControlWithMonitoring);
+ await driver.setUp(precondition.hasAuthenticationEnabled());
+
+ await driver.goTo("/dashboard");
+
+ await waitFor(() => {
+ expect(screen.getByText(/Dashboard/i)).toBeInTheDocument();
+ });
+
+ // Verify the service control URL is configured as relative (YARP mode)
+ expect(window.defaultConfig.service_control_url).toBe("/api/");
+ expect(window.defaultConfig.monitoring_urls[0]).toBe("/monitoring-api/");
+
+ // Verify auth token is set (YARP would forward this to backend)
+ expect(sessionStorage.getItem("auth_token")).toBe(defaultMockUser.access_token);
+ });
+ });
+});
diff --git a/src/Frontend/vite.config.ts b/src/Frontend/vite.config.ts
index f8b73aa881..37f5ca9ac2 100644
--- a/src/Frontend/vite.config.ts
+++ b/src/Frontend/vite.config.ts
@@ -25,6 +25,7 @@ const port = 5173;
const defaultUrls = [
"http://10.211.55.3:*", // The default Parallels url to access Windows VM
"http://localhost:*",
+ "https://*",
];
// https://vitejs.dev/config/
diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/DebugRequestInfoResponse.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/DebugRequestInfoResponse.cs
new file mode 100644
index 0000000000..6b20807ff1
--- /dev/null
+++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/DebugRequestInfoResponse.cs
@@ -0,0 +1,58 @@
+namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders
+{
+ using System.Text.Json.Serialization;
+
+ ///
+ /// Represents the JSON response from the /debug/request-info endpoint.
+ ///
+ public class DebugRequestInfoResponse
+ {
+ [JsonPropertyName("processed")]
+ public ProcessedValues Processed { get; set; }
+
+ [JsonPropertyName("rawHeaders")]
+ public RawHeaders RawHeaders { get; set; }
+
+ [JsonPropertyName("configuration")]
+ public ConfigurationValues Configuration { get; set; }
+ }
+
+ public class ProcessedValues
+ {
+ [JsonPropertyName("scheme")]
+ public string Scheme { get; set; }
+
+ [JsonPropertyName("host")]
+ public string Host { get; set; }
+
+ [JsonPropertyName("remoteIpAddress")]
+ public string RemoteIpAddress { get; set; }
+ }
+
+ public class RawHeaders
+ {
+ [JsonPropertyName("xForwardedFor")]
+ public string XForwardedFor { get; set; }
+
+ [JsonPropertyName("xForwardedProto")]
+ public string XForwardedProto { get; set; }
+
+ [JsonPropertyName("xForwardedHost")]
+ public string XForwardedHost { get; set; }
+ }
+
+ public class ConfigurationValues
+ {
+ [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/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/ForwardedHeadersTestBase.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/ForwardedHeadersTestBase.cs
new file mode 100644
index 0000000000..22675aa11f
--- /dev/null
+++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/ForwardedHeadersTestBase.cs
@@ -0,0 +1,57 @@
+namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders
+{
+ using System.Net.Http;
+ using System.Text.Json;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Owin.Testing;
+ using NUnit.Framework;
+
+ ///
+ /// Base class for forwarded headers acceptance tests providing common setup, teardown, and helper methods.
+ ///
+ public abstract class ForwardedHeadersTestBase
+ {
+ protected TestServer Server;
+
+ [TearDown]
+ public void TearDown()
+ {
+ Server?.Dispose();
+ ForwardedHeadersTestStartup.Reset();
+ }
+
+ protected void CreateServer()
+ {
+ Server = TestServer.Create();
+ }
+
+ protected async Task SendRequestWithHeaders(
+ string forwardedFor = null,
+ string forwardedProto = null,
+ string forwardedHost = null,
+ CancellationToken cancellationToken = default)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Get, "/debug/request-info");
+
+ if (forwardedFor != null)
+ {
+ request.Headers.Add("X-Forwarded-For", forwardedFor);
+ }
+ if (forwardedProto != null)
+ {
+ request.Headers.Add("X-Forwarded-Proto", forwardedProto);
+ }
+ if (forwardedHost != null)
+ {
+ request.Headers.Add("X-Forwarded-Host", forwardedHost);
+ }
+
+ var response = await Server.HttpClient.SendAsync(request, cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var content = await response.Content.ReadAsStringAsync();
+ return JsonSerializer.Deserialize(content);
+ }
+ }
+}
diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/ForwardedHeadersTestStartup.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/ForwardedHeadersTestStartup.cs
new file mode 100644
index 0000000000..38082b3c8f
--- /dev/null
+++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/ForwardedHeadersTestStartup.cs
@@ -0,0 +1,182 @@
+namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders
+{
+ using System.Collections.Generic;
+ using System.Net;
+ using System.Threading.Tasks;
+ using Microsoft.Owin;
+ using global::Owin;
+ using ServicePulse.Host.Owin;
+
+ ///
+ /// Test OWIN startup class that configures the forwarded headers middleware pipeline
+ /// with the debug endpoint enabled for acceptance testing.
+ ///
+ public class ForwardedHeadersTestStartup
+ {
+ public static ForwardedHeadersOptions ForwardedHeadersOptions { get; set; } = new ForwardedHeadersOptions();
+
+ ///
+ /// The remote IP address to simulate for incoming requests.
+ /// Microsoft.Owin.Testing doesn't set RemoteIpAddress, so we inject it.
+ ///
+ public static string SimulatedRemoteIpAddress { get; set; } = "127.0.0.1";
+
+ public void Configuration(IAppBuilder app)
+ {
+ // Inject simulated remote IP for testing (TestServer doesn't set this)
+ app.Use(SimulatedRemoteIpAddress);
+
+ // Forwarded headers must be first in the pipeline
+ app.UseForwardedHeaders(ForwardedHeadersOptions);
+
+ // Debug endpoint for testing - always enabled in tests
+ app.UseDebugRequestInfo(ForwardedHeadersOptions);
+ }
+
+ public static void Reset()
+ {
+ ForwardedHeadersOptions = new ForwardedHeadersOptions();
+ SimulatedRemoteIpAddress = "127.0.0.1";
+ }
+
+ public static void ConfigureDefaults()
+ {
+ ForwardedHeadersOptions = new ForwardedHeadersOptions
+ {
+ Enabled = true,
+ TrustAllProxies = true
+ };
+ SimulatedRemoteIpAddress = "127.0.0.1";
+ }
+
+ public static void ConfigureDisabled()
+ {
+ ForwardedHeadersOptions = new ForwardedHeadersOptions
+ {
+ Enabled = false
+ };
+ SimulatedRemoteIpAddress = "127.0.0.1";
+ }
+
+ public static void ConfigureWithKnownProxies(params string[] proxies)
+ {
+ var knownProxies = new List();
+ foreach (var proxy in proxies)
+ {
+ if (IPAddress.TryParse(proxy, out var ip))
+ {
+ knownProxies.Add(ip);
+ }
+ }
+
+ ForwardedHeadersOptions = new ForwardedHeadersOptions
+ {
+ Enabled = true,
+ TrustAllProxies = false,
+ KnownProxies = knownProxies
+ };
+ SimulatedRemoteIpAddress = "127.0.0.1";
+ }
+
+ public static void ConfigureWithKnownNetworks(params string[] networks)
+ {
+ var knownNetworks = new List();
+ foreach (var network in networks)
+ {
+ if (CidrNetwork.TryParse(network, out var cidr))
+ {
+ knownNetworks.Add(cidr);
+ }
+ }
+
+ ForwardedHeadersOptions = new ForwardedHeadersOptions
+ {
+ Enabled = true,
+ TrustAllProxies = false,
+ KnownNetworks = knownNetworks
+ };
+ SimulatedRemoteIpAddress = "127.0.0.1";
+ }
+
+ public static void ConfigureWithKnownProxiesAndNetworks(string[] proxies, string[] networks)
+ {
+ var knownProxies = new List();
+ foreach (var proxy in proxies)
+ {
+ if (IPAddress.TryParse(proxy, out var ip))
+ {
+ knownProxies.Add(ip);
+ }
+ }
+
+ var knownNetworks = new List();
+ foreach (var network in networks)
+ {
+ if (CidrNetwork.TryParse(network, out var cidr))
+ {
+ knownNetworks.Add(cidr);
+ }
+ }
+
+ ForwardedHeadersOptions = new ForwardedHeadersOptions
+ {
+ Enabled = true,
+ TrustAllProxies = false,
+ KnownProxies = knownProxies,
+ KnownNetworks = knownNetworks
+ };
+ SimulatedRemoteIpAddress = "127.0.0.1";
+ }
+
+ ///
+ /// Configure with unknown proxy - simulates a request from an untrusted IP
+ ///
+ public static void ConfigureWithUnknownProxy()
+ {
+ ForwardedHeadersOptions = new ForwardedHeadersOptions
+ {
+ Enabled = true,
+ TrustAllProxies = false,
+ KnownProxies = new List { IPAddress.Parse("10.0.0.1") }
+ };
+ // Simulate request from a different IP than the known proxy
+ SimulatedRemoteIpAddress = "192.168.1.100";
+ }
+
+ ///
+ /// Configure with unknown network - simulates a request from outside trusted networks
+ ///
+ public static void ConfigureWithUnknownNetwork()
+ {
+ ForwardedHeadersOptions = new ForwardedHeadersOptions
+ {
+ Enabled = true,
+ TrustAllProxies = false,
+ KnownNetworks = new List { CidrNetwork.TryParse("10.0.0.0/8", out var n) ? n : null }
+ };
+ // Simulate request from an IP outside the known network
+ SimulatedRemoteIpAddress = "192.168.1.100";
+ }
+ }
+
+ ///
+ /// Middleware that injects a simulated remote IP address for testing.
+ /// Microsoft.Owin.Testing's TestServer doesn't populate the RemoteIpAddress.
+ ///
+ public class RemoteIpSimulatorMiddleware : OwinMiddleware
+ {
+ readonly string remoteIpAddress;
+
+ public RemoteIpSimulatorMiddleware(OwinMiddleware next, string remoteIpAddress) : base(next)
+ {
+ this.remoteIpAddress = remoteIpAddress;
+ }
+
+ public override Task Invoke(IOwinContext context)
+ {
+ // Set the server.RemoteIpAddress environment variable
+ context.Environment["server.RemoteIpAddress"] = remoteIpAddress;
+ return Next.Invoke(context);
+ }
+ }
+}
diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_forwarded_headers_are_disabled.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_forwarded_headers_are_disabled.cs
new file mode 100644
index 0000000000..21c572d297
--- /dev/null
+++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_forwarded_headers_are_disabled.cs
@@ -0,0 +1,47 @@
+namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using NUnit.Framework;
+
+ ///
+ /// Tests behavior when forwarded headers processing is disabled.
+ ///
+ [TestFixture]
+ public class When_forwarded_headers_are_disabled : ForwardedHeadersTestBase
+ {
+ [SetUp]
+ public void SetUp()
+ {
+ ForwardedHeadersTestStartup.ConfigureDisabled();
+ CreateServer();
+ }
+
+ [Test]
+ public async Task Headers_should_not_be_processed()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ // When disabled, forwarded headers should not affect processed values
+ Assert.That(result.Processed.Scheme, Is.EqualTo("http"));
+ Assert.That(result.Processed.Host, Is.Not.EqualTo("example.com"));
+ Assert.That(result.Processed.RemoteIpAddress, Is.Not.EqualTo("203.0.113.50"));
+ }
+
+ [Test]
+ public async Task Headers_should_remain_unmodified()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ // Headers should remain intact when processing is disabled
+ Assert.That(result.RawHeaders.XForwardedFor, Is.EqualTo("203.0.113.50"));
+ Assert.That(result.RawHeaders.XForwardedProto, Is.EqualTo("https"));
+ Assert.That(result.RawHeaders.XForwardedHost, Is.EqualTo("example.com"));
+ }
+ }
+}
diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_forwarded_headers_are_sent.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_forwarded_headers_are_sent.cs
new file mode 100644
index 0000000000..3d0010c5b0
--- /dev/null
+++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_forwarded_headers_are_sent.cs
@@ -0,0 +1,46 @@
+namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using NUnit.Framework;
+
+ ///
+ /// Tests default behavior when forwarded headers are sent and all proxies are trusted (default configuration).
+ ///
+ [TestFixture]
+ public class When_forwarded_headers_are_sent : ForwardedHeadersTestBase
+ {
+ [SetUp]
+ public void SetUp()
+ {
+ ForwardedHeadersTestStartup.ConfigureDefaults();
+ CreateServer();
+ }
+
+ [Test]
+ public async Task Headers_should_be_applied_when_trust_all_proxies()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ Assert.That(result.Processed.Scheme, Is.EqualTo("https"));
+ Assert.That(result.Processed.Host, Is.EqualTo("example.com"));
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50"));
+ }
+
+ [Test]
+ public async Task Headers_should_be_consumed_after_processing()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ // Headers should be consumed (removed) after processing
+ Assert.That(result.RawHeaders.XForwardedFor, Is.Empty);
+ Assert.That(result.RawHeaders.XForwardedProto, Is.Empty);
+ Assert.That(result.RawHeaders.XForwardedHost, Is.Empty);
+ }
+ }
+}
diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_only_proto_header_is_sent.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_only_proto_header_is_sent.cs
new file mode 100644
index 0000000000..3dea0a1aac
--- /dev/null
+++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_only_proto_header_is_sent.cs
@@ -0,0 +1,38 @@
+namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using NUnit.Framework;
+
+ ///
+ /// Tests behavior when only the X-Forwarded-Proto header is sent.
+ /// Common scenario for reverse proxies that only indicate the protocol.
+ ///
+ [TestFixture]
+ public class When_only_proto_header_is_sent : ForwardedHeadersTestBase
+ {
+ [SetUp]
+ public void SetUp()
+ {
+ ForwardedHeadersTestStartup.ConfigureDefaults();
+ CreateServer();
+ }
+
+ [Test]
+ public async Task Scheme_should_be_updated()
+ {
+ var result = await SendRequestWithHeaders(forwardedProto: "https");
+
+ Assert.That(result.Processed.Scheme, Is.EqualTo("https"));
+ }
+
+ [Test]
+ public async Task Host_and_ip_should_use_original_values()
+ {
+ var result = await SendRequestWithHeaders(forwardedProto: "https");
+
+ // Host and IP should use original values since only proto was forwarded
+ Assert.That(result.Processed.Host, Is.Not.Empty);
+ // RemoteIpAddress should be the original (not from X-Forwarded-For)
+ }
+ }
+}
diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs
new file mode 100644
index 0000000000..7a62e26beb
--- /dev/null
+++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs
@@ -0,0 +1,68 @@
+namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using NUnit.Framework;
+
+ ///
+ /// Tests behavior when multiple values are in the X-Forwarded-* headers (proxy chain).
+ /// The leftmost (first) entry represents the original client's value.
+ ///
+ [TestFixture]
+ public class When_proxy_chain_headers_are_sent : ForwardedHeadersTestBase
+ {
+ [SetUp]
+ public void SetUp()
+ {
+ ForwardedHeadersTestStartup.ConfigureDefaults();
+ CreateServer();
+ }
+
+ [Test]
+ public async Task Should_use_leftmost_ip_when_trust_all_proxies()
+ {
+ // Proxy chain: client -> proxy1 -> proxy2 -> server
+ // X-Forwarded-For format: client, proxy1, proxy2
+ // TrustAllProxies=true processes all entries, ending with leftmost (original client)
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1");
+
+ // TrustAllProxies=true processes entire chain, returns leftmost (original client)
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50"));
+ }
+
+ [Test]
+ public async Task Should_use_leftmost_proto_from_chain()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedProto: "https, http");
+
+ // Should use leftmost protocol (original client's scheme)
+ Assert.That(result.Processed.Scheme, Is.EqualTo("https"));
+ }
+
+ [Test]
+ public async Task Should_use_leftmost_host_from_chain()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedHost: "first.example.com, second.example.com");
+
+ // Should use leftmost host (original client's host)
+ Assert.That(result.Processed.Host, Is.EqualTo("first.example.com"));
+ }
+
+ [Test]
+ public async Task Should_consume_all_entries_when_trust_all_proxies()
+ {
+ // TrustAllProxies=true consumes all entries from all headers (consistent with ASP.NET Core)
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1",
+ forwardedProto: "https, http",
+ forwardedHost: "first.example.com, second.example.com");
+
+ // All headers fully consumed when TrustAllProxies=true
+ Assert.That(result.RawHeaders.XForwardedFor, Is.Empty);
+ Assert.That(result.RawHeaders.XForwardedProto, Is.Empty);
+ Assert.That(result.RawHeaders.XForwardedHost, Is.Empty);
+ }
+ }
+}
diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_request_has_no_forwarded_headers.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_request_has_no_forwarded_headers.cs
new file mode 100644
index 0000000000..1437b17191
--- /dev/null
+++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_request_has_no_forwarded_headers.cs
@@ -0,0 +1,32 @@
+namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using NUnit.Framework;
+
+ ///
+ /// Tests behavior when request has no forwarded headers.
+ ///
+ [TestFixture]
+ public class When_request_has_no_forwarded_headers : ForwardedHeadersTestBase
+ {
+ [SetUp]
+ public void SetUp()
+ {
+ ForwardedHeadersTestStartup.ConfigureDefaults();
+ CreateServer();
+ }
+
+ [Test]
+ public async Task Original_values_should_be_preserved()
+ {
+ // No forwarded headers
+ var result = await SendRequestWithHeaders();
+
+ // Original request values should be used
+ Assert.That(result.Processed.Scheme, Is.EqualTo("http"));
+ Assert.That(result.RawHeaders.XForwardedFor, Is.Empty);
+ Assert.That(result.RawHeaders.XForwardedProto, Is.Empty);
+ Assert.That(result.RawHeaders.XForwardedHost, Is.Empty);
+ }
+ }
+}
diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_trusted_source_sends_headers.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_trusted_source_sends_headers.cs
new file mode 100644
index 0000000000..cdf160bb22
--- /dev/null
+++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_trusted_source_sends_headers.cs
@@ -0,0 +1,105 @@
+namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using NUnit.Framework;
+
+ ///
+ /// Tests behavior when headers are sent from trusted sources (known proxies or networks).
+ /// Headers should be processed when the request comes from a trusted source.
+ ///
+ [TestFixture]
+ public class When_trusted_source_sends_headers : ForwardedHeadersTestBase
+ {
+ [Test]
+ public async Task Headers_should_be_processed_from_known_proxy()
+ {
+ ForwardedHeadersTestStartup.ConfigureWithKnownProxies("127.0.0.1", "::1");
+ CreateServer();
+
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ Assert.That(result.Processed.Scheme, Is.EqualTo("https"));
+ Assert.That(result.Processed.Host, Is.EqualTo("example.com"));
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50"));
+ }
+
+ [Test]
+ public async Task Headers_should_be_processed_from_known_network()
+ {
+ ForwardedHeadersTestStartup.ConfigureWithKnownNetworks("127.0.0.0/8", "::1/128");
+ CreateServer();
+
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ Assert.That(result.Processed.Scheme, Is.EqualTo("https"));
+ Assert.That(result.Processed.Host, Is.EqualTo("example.com"));
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50"));
+ }
+
+ [Test]
+ public async Task Configuration_should_show_known_proxies()
+ {
+ ForwardedHeadersTestStartup.ConfigureWithKnownProxies("127.0.0.1", "::1");
+ CreateServer();
+
+ var result = await SendRequestWithHeaders();
+
+ Assert.That(result.Configuration.Enabled, Is.True);
+ Assert.That(result.Configuration.TrustAllProxies, Is.False);
+ Assert.That(result.Configuration.KnownProxies, Has.Length.GreaterThan(0));
+ }
+
+ [Test]
+ public async Task Configuration_should_show_known_networks()
+ {
+ ForwardedHeadersTestStartup.ConfigureWithKnownNetworks("127.0.0.0/8", "::1/128");
+ CreateServer();
+
+ var result = await SendRequestWithHeaders();
+
+ Assert.That(result.Configuration.Enabled, Is.True);
+ Assert.That(result.Configuration.TrustAllProxies, Is.False);
+ Assert.That(result.Configuration.KnownNetworks, Has.Length.GreaterThan(0));
+ }
+
+ [Test]
+ public async Task Headers_should_be_processed_when_matching_either_proxy_or_network()
+ {
+ ForwardedHeadersTestStartup.ConfigureWithKnownProxiesAndNetworks(
+ new[] { "127.0.0.1" },
+ new[] { "::1/128" });
+ CreateServer();
+
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ Assert.That(result.Processed.Scheme, Is.EqualTo("https"));
+ Assert.That(result.Processed.Host, Is.EqualTo("example.com"));
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50"));
+ }
+
+ [Test]
+ public async Task Configuration_should_show_both_proxies_and_networks()
+ {
+ ForwardedHeadersTestStartup.ConfigureWithKnownProxiesAndNetworks(
+ new[] { "127.0.0.1" },
+ new[] { "::1/128" });
+ CreateServer();
+
+ var result = await SendRequestWithHeaders();
+
+ Assert.That(result.Configuration.Enabled, Is.True);
+ Assert.That(result.Configuration.TrustAllProxies, Is.False);
+ Assert.That(result.Configuration.KnownProxies, Has.Length.GreaterThan(0));
+ Assert.That(result.Configuration.KnownNetworks, Has.Length.GreaterThan(0));
+ }
+ }
+}
diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_untrusted_source_sends_headers.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_untrusted_source_sends_headers.cs
new file mode 100644
index 0000000000..07f13f1c06
--- /dev/null
+++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_untrusted_source_sends_headers.cs
@@ -0,0 +1,77 @@
+namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using NUnit.Framework;
+
+ ///
+ /// Tests behavior when headers are sent from untrusted sources.
+ /// When known proxies/networks are configured but request doesn't come from one, headers should be ignored.
+ ///
+ [TestFixture]
+ public class When_untrusted_source_sends_headers : ForwardedHeadersTestBase
+ {
+ [Test]
+ public async Task Headers_should_not_be_processed_from_unknown_proxy()
+ {
+ ForwardedHeadersTestStartup.ConfigureWithUnknownProxy();
+ CreateServer();
+
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ Assert.That(result.Processed.Scheme, Is.EqualTo("http"));
+ Assert.That(result.Processed.Host, Is.Not.EqualTo("example.com"));
+ Assert.That(result.Processed.RemoteIpAddress, Is.Not.EqualTo("203.0.113.50"));
+ }
+
+ [Test]
+ public async Task Headers_should_not_be_processed_from_unknown_network()
+ {
+ ForwardedHeadersTestStartup.ConfigureWithUnknownNetwork();
+ CreateServer();
+
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ Assert.That(result.Processed.Scheme, Is.EqualTo("http"));
+ Assert.That(result.Processed.Host, Is.Not.EqualTo("example.com"));
+ Assert.That(result.Processed.RemoteIpAddress, Is.Not.EqualTo("203.0.113.50"));
+ }
+
+ [Test]
+ public async Task Headers_should_remain_in_request_from_unknown_proxy()
+ {
+ ForwardedHeadersTestStartup.ConfigureWithUnknownProxy();
+ CreateServer();
+
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ Assert.That(result.RawHeaders.XForwardedFor, Is.EqualTo("203.0.113.50"));
+ Assert.That(result.RawHeaders.XForwardedProto, Is.EqualTo("https"));
+ Assert.That(result.RawHeaders.XForwardedHost, Is.EqualTo("example.com"));
+ }
+
+ [Test]
+ public async Task Headers_should_remain_in_request_from_unknown_network()
+ {
+ ForwardedHeadersTestStartup.ConfigureWithUnknownNetwork();
+ CreateServer();
+
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ Assert.That(result.RawHeaders.XForwardedFor, Is.EqualTo("203.0.113.50"));
+ Assert.That(result.RawHeaders.XForwardedProto, Is.EqualTo("https"));
+ Assert.That(result.RawHeaders.XForwardedHost, Is.EqualTo("example.com"));
+ }
+ }
+}
diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/Https/HttpsTestBase.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/HttpsTestBase.cs
new file mode 100644
index 0000000000..69a6b33341
--- /dev/null
+++ b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/HttpsTestBase.cs
@@ -0,0 +1,80 @@
+namespace ServicePulse.Host.Tests.AcceptanceTests.Https
+{
+ using System.Net.Http;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Owin.Testing;
+ using NUnit.Framework;
+
+ ///
+ /// Base class for HTTPS acceptance tests providing common setup, teardown, and helper methods.
+ ///
+ public abstract class HttpsTestBase
+ {
+ protected TestServer Server;
+
+ [TearDown]
+ public void TearDown()
+ {
+ Server?.Dispose();
+ HttpsTestStartup.Reset();
+ }
+
+ protected void CreateServer()
+ {
+ Server = TestServer.Create();
+ }
+
+ ///
+ /// Creates an HttpClient that does NOT follow redirects (to test redirect behavior).
+ ///
+ protected HttpClient CreateNonRedirectingClient()
+ {
+ return new HttpClient(Server.Handler) { BaseAddress = Server.BaseAddress };
+ }
+
+ ///
+ /// Sends a request and returns the response, optionally with forwarded headers.
+ ///
+ protected async Task SendRequest(
+ string path,
+ HttpMethod method = null,
+ string forwardedProto = null,
+ string forwardedHost = null,
+ bool followRedirects = true,
+ CancellationToken cancellationToken = default)
+ {
+ var request = new HttpRequestMessage(method ?? HttpMethod.Get, path);
+
+ if (forwardedProto != null)
+ {
+ request.Headers.Add("X-Forwarded-Proto", forwardedProto);
+ }
+ if (forwardedHost != null)
+ {
+ request.Headers.Add("X-Forwarded-Host", forwardedHost);
+ }
+
+ if (followRedirects)
+ {
+ return await Server.HttpClient.SendAsync(request, cancellationToken);
+ }
+ else
+ {
+ using (var client = CreateNonRedirectingClient())
+ {
+ return await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
+ }
+ }
+ }
+
+ protected static string GetHstsHeaderValue(HttpResponseMessage response)
+ {
+ if (response.Headers.Contains("Strict-Transport-Security"))
+ {
+ return string.Join("", response.Headers.GetValues("Strict-Transport-Security"));
+ }
+ return null;
+ }
+ }
+}
diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/Https/HttpsTestStartup.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/HttpsTestStartup.cs
new file mode 100644
index 0000000000..c459b97e6b
--- /dev/null
+++ b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/HttpsTestStartup.cs
@@ -0,0 +1,179 @@
+namespace ServicePulse.Host.Tests.AcceptanceTests.Https
+{
+ using System.Threading.Tasks;
+ using Microsoft.Owin;
+ using global::Owin;
+ using ServicePulse.Host.Owin;
+
+ ///
+ /// Test OWIN startup class that configures the HTTPS middleware pipeline
+ /// with a debug endpoint for acceptance testing.
+ ///
+ public class HttpsTestStartup
+ {
+ public static HttpsOptions HttpsOptions { get; set; } = new HttpsOptions();
+ public static ForwardedHeadersOptions ForwardedHeadersOptions { get; set; } = new ForwardedHeadersOptions();
+
+ ///
+ /// The scheme to simulate for incoming requests (http or https).
+ ///
+ public static string SimulatedScheme { get; set; } = "http";
+
+ ///
+ /// The host to simulate for incoming requests.
+ ///
+ public static string SimulatedHost { get; set; } = "localhost";
+
+ public void Configuration(IAppBuilder app)
+ {
+ // Inject simulated request properties for testing
+ app.Use(SimulatedScheme, SimulatedHost);
+
+ // Forwarded headers middleware (processes X-Forwarded-* headers)
+ app.UseForwardedHeaders(ForwardedHeadersOptions);
+
+ // HTTPS middleware (HSTS and redirect)
+ app.UseHttpsMiddleware(HttpsOptions);
+
+ // Debug endpoint that returns request info
+ app.Use();
+ }
+
+ public static void Reset()
+ {
+ HttpsOptions = new HttpsOptions();
+ ForwardedHeadersOptions = new ForwardedHeadersOptions { Enabled = false };
+ SimulatedScheme = "http";
+ SimulatedHost = "localhost";
+ }
+
+ public static void ConfigureHstsEnabled(int maxAgeSeconds = 31536000, bool includeSubDomains = false)
+ {
+ HttpsOptions = new HttpsOptions
+ {
+ EnableHsts = true,
+ HstsMaxAgeSeconds = maxAgeSeconds,
+ HstsIncludeSubDomains = includeSubDomains
+ };
+ ForwardedHeadersOptions = new ForwardedHeadersOptions { Enabled = false };
+ SimulatedScheme = "https";
+ SimulatedHost = "localhost";
+ }
+
+ public static void ConfigureRedirectEnabled(int? httpsPort = null)
+ {
+ HttpsOptions = new HttpsOptions
+ {
+ RedirectHttpToHttps = true,
+ Port = httpsPort
+ };
+ ForwardedHeadersOptions = new ForwardedHeadersOptions { Enabled = false };
+ SimulatedScheme = "http";
+ SimulatedHost = "localhost";
+ }
+
+ public static void ConfigureAllEnabled(int? httpsPort = null, int maxAgeSeconds = 31536000, bool includeSubDomains = false)
+ {
+ HttpsOptions = new HttpsOptions
+ {
+ Enabled = true,
+ RedirectHttpToHttps = true,
+ Port = httpsPort,
+ EnableHsts = true,
+ HstsMaxAgeSeconds = maxAgeSeconds,
+ HstsIncludeSubDomains = includeSubDomains
+ };
+ ForwardedHeadersOptions = new ForwardedHeadersOptions { Enabled = false };
+ SimulatedScheme = "http";
+ SimulatedHost = "localhost";
+ }
+
+ public static void ConfigureDisabled()
+ {
+ HttpsOptions = new HttpsOptions
+ {
+ Enabled = false,
+ RedirectHttpToHttps = false,
+ EnableHsts = false
+ };
+ ForwardedHeadersOptions = new ForwardedHeadersOptions { Enabled = false };
+ SimulatedScheme = "http";
+ SimulatedHost = "localhost";
+ }
+
+ public static void ConfigureWithForwardedHeaders()
+ {
+ HttpsOptions = new HttpsOptions
+ {
+ RedirectHttpToHttps = true,
+ EnableHsts = true
+ };
+ ForwardedHeadersOptions = new ForwardedHeadersOptions
+ {
+ Enabled = true,
+ TrustAllProxies = true
+ };
+ SimulatedScheme = "http";
+ SimulatedHost = "localhost";
+ }
+ }
+
+ ///
+ /// Middleware that injects simulated request properties for testing.
+ ///
+ public class RequestSimulatorMiddleware : OwinMiddleware
+ {
+ readonly string scheme;
+ readonly string host;
+
+ public RequestSimulatorMiddleware(OwinMiddleware next, string scheme, string host) : base(next)
+ {
+ this.scheme = scheme;
+ this.host = host;
+ }
+
+ public override Task Invoke(IOwinContext context)
+ {
+ context.Request.Scheme = scheme;
+ context.Request.Host = new HostString(host);
+ return Next.Invoke(context);
+ }
+ }
+
+ ///
+ /// Debug middleware that returns request information as JSON.
+ ///
+ public class HttpsDebugMiddleware : OwinMiddleware
+ {
+ public HttpsDebugMiddleware(OwinMiddleware next) : base(next) { }
+
+ public override async Task Invoke(IOwinContext context)
+ {
+ if (context.Request.Path.ToString().Equals("/debug/https-info", System.StringComparison.OrdinalIgnoreCase))
+ {
+ var response = context.Response;
+ response.ContentType = "application/json";
+
+ var scheme = context.Environment.TryGetValue("owin.RequestScheme", out var envScheme)
+ ? envScheme?.ToString() ?? context.Request.Scheme
+ : context.Request.Scheme;
+
+ var host = context.Environment.TryGetValue("host.RequestHost", out var envHost)
+ ? envHost?.ToString() ?? context.Request.Host.ToString()
+ : context.Request.Host.ToString();
+
+ var json = $@"{{
+ ""scheme"": ""{scheme}"",
+ ""host"": ""{host}"",
+ ""path"": ""{context.Request.Path}"",
+ ""queryString"": ""{context.Request.QueryString}""
+}}";
+
+ await response.WriteAsync(json).ConfigureAwait(false);
+ return;
+ }
+
+ await Next.Invoke(context).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_hsts_is_enabled.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_hsts_is_enabled.cs
new file mode 100644
index 0000000000..3ee5d83b7b
--- /dev/null
+++ b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_hsts_is_enabled.cs
@@ -0,0 +1,80 @@
+namespace ServicePulse.Host.Tests.AcceptanceTests.Https
+{
+ using System.Threading.Tasks;
+ using NUnit.Framework;
+
+ ///
+ /// Tests HSTS (HTTP Strict Transport Security) header behavior.
+ /// HSTS should only be added to HTTPS responses.
+ ///
+ [TestFixture]
+ public class When_hsts_is_enabled : HttpsTestBase
+ {
+ [Test]
+ public async Task Should_add_hsts_header_for_https_request()
+ {
+ HttpsTestStartup.ConfigureHstsEnabled();
+ HttpsTestStartup.SimulatedScheme = "https";
+ CreateServer();
+
+ var response = await SendRequest("/debug/https-info");
+ response.EnsureSuccessStatusCode();
+
+ Assert.That(response.Headers.Contains("Strict-Transport-Security"), Is.True);
+ var hstsValue = GetHstsHeaderValue(response);
+ Assert.That(hstsValue, Does.Contain("max-age=31536000"));
+ }
+
+ [Test]
+ public async Task Should_not_add_hsts_header_for_http_request()
+ {
+ HttpsTestStartup.ConfigureHstsEnabled();
+ HttpsTestStartup.SimulatedScheme = "http";
+ CreateServer();
+
+ var response = await SendRequest("/debug/https-info");
+ response.EnsureSuccessStatusCode();
+
+ Assert.That(response.Headers.Contains("Strict-Transport-Security"), Is.False);
+ }
+
+ [Test]
+ public async Task Should_use_custom_max_age()
+ {
+ HttpsTestStartup.ConfigureHstsEnabled(maxAgeSeconds: 86400);
+ HttpsTestStartup.SimulatedScheme = "https";
+ CreateServer();
+
+ var response = await SendRequest("/debug/https-info");
+ response.EnsureSuccessStatusCode();
+
+ Assert.That(GetHstsHeaderValue(response), Is.EqualTo("max-age=86400"));
+ }
+
+ [Test]
+ public async Task Should_include_subdomains_when_configured()
+ {
+ HttpsTestStartup.ConfigureHstsEnabled(includeSubDomains: true);
+ HttpsTestStartup.SimulatedScheme = "https";
+ CreateServer();
+
+ var response = await SendRequest("/debug/https-info");
+ response.EnsureSuccessStatusCode();
+
+ Assert.That(GetHstsHeaderValue(response), Does.Contain("includeSubDomains"));
+ }
+
+ [Test]
+ public async Task Should_not_include_subdomains_by_default()
+ {
+ HttpsTestStartup.ConfigureHstsEnabled(includeSubDomains: false);
+ HttpsTestStartup.SimulatedScheme = "https";
+ CreateServer();
+
+ var response = await SendRequest("/debug/https-info");
+ response.EnsureSuccessStatusCode();
+
+ Assert.That(GetHstsHeaderValue(response), Does.Not.Contain("includeSubDomains"));
+ }
+ }
+}
diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_https_is_disabled.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_https_is_disabled.cs
new file mode 100644
index 0000000000..006bf18e3a
--- /dev/null
+++ b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_https_is_disabled.cs
@@ -0,0 +1,55 @@
+namespace ServicePulse.Host.Tests.AcceptanceTests.Https
+{
+ using System.Net;
+ using System.Threading.Tasks;
+ using NUnit.Framework;
+
+ ///
+ /// Tests behavior when HTTPS features are disabled.
+ /// No HSTS header should be added and no redirects should occur.
+ ///
+ [TestFixture]
+ public class When_https_is_disabled : HttpsTestBase
+ {
+ [SetUp]
+ public void SetUp()
+ {
+ HttpsTestStartup.ConfigureDisabled();
+ CreateServer();
+ }
+
+ [Test]
+ public async Task Should_not_add_hsts_header()
+ {
+ HttpsTestStartup.SimulatedScheme = "https";
+
+ var response = await SendRequest("/debug/https-info");
+ response.EnsureSuccessStatusCode();
+
+ Assert.That(response.Headers.Contains("Strict-Transport-Security"), Is.False);
+ }
+
+ [Test]
+ public async Task Should_not_redirect_http_request()
+ {
+ HttpsTestStartup.SimulatedScheme = "http";
+
+ var response = await SendRequest("/debug/https-info");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ Assert.That(response.Headers.Location, Is.Null);
+ }
+
+ [Test]
+ public async Task Should_pass_through_to_next_middleware()
+ {
+ HttpsTestStartup.SimulatedScheme = "http";
+
+ var response = await SendRequest("/debug/https-info");
+ response.EnsureSuccessStatusCode();
+
+ var content = await response.Content.ReadAsStringAsync();
+ Assert.That(content, Does.Contain("\"scheme\": \"http\""));
+ }
+ }
+}
diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_https_redirect_is_enabled.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_https_redirect_is_enabled.cs
new file mode 100644
index 0000000000..51c22ee60d
--- /dev/null
+++ b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_https_redirect_is_enabled.cs
@@ -0,0 +1,140 @@
+namespace ServicePulse.Host.Tests.AcceptanceTests.Https
+{
+ using System.Net;
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using NUnit.Framework;
+
+ ///
+ /// Tests HTTP to HTTPS redirect behavior.
+ /// HTTP requests should be redirected with 307 status code.
+ ///
+ [TestFixture]
+ public class When_https_redirect_is_enabled : HttpsTestBase
+ {
+ [Test]
+ public async Task Should_redirect_http_to_https()
+ {
+ HttpsTestStartup.ConfigureRedirectEnabled();
+ HttpsTestStartup.SimulatedScheme = "http";
+ HttpsTestStartup.SimulatedHost = "example.com";
+ CreateServer();
+
+ var response = await SendRequest("/some/path", followRedirects: false);
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ Assert.That(response.Headers.Location.ToString(), Is.EqualTo("https://example.com/some/path"));
+ }
+
+ [Test]
+ public async Task Should_not_redirect_https_request()
+ {
+ HttpsTestStartup.ConfigureRedirectEnabled();
+ HttpsTestStartup.SimulatedScheme = "https";
+ CreateServer();
+
+ var response = await SendRequest("/debug/https-info");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ }
+
+ [Test]
+ public async Task Should_include_custom_port_in_redirect()
+ {
+ HttpsTestStartup.ConfigureRedirectEnabled(httpsPort: 8443);
+ HttpsTestStartup.SimulatedScheme = "http";
+ HttpsTestStartup.SimulatedHost = "example.com";
+ CreateServer();
+
+ var response = await SendRequest("/test", followRedirects: false);
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ Assert.That(response.Headers.Location.ToString(), Is.EqualTo("https://example.com:8443/test"));
+ }
+
+ [Test]
+ public async Task Should_omit_default_https_port()
+ {
+ HttpsTestStartup.ConfigureRedirectEnabled(httpsPort: 443);
+ HttpsTestStartup.SimulatedScheme = "http";
+ HttpsTestStartup.SimulatedHost = "example.com";
+ CreateServer();
+
+ var response = await SendRequest("/test", followRedirects: false);
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ Assert.That(response.Headers.Location.ToString(), Is.EqualTo("https://example.com/test"));
+ Assert.That(response.Headers.Location.ToString(), Does.Not.Contain(":443"));
+ }
+
+ [Test]
+ public async Task Should_preserve_query_string()
+ {
+ HttpsTestStartup.ConfigureRedirectEnabled();
+ HttpsTestStartup.SimulatedScheme = "http";
+ HttpsTestStartup.SimulatedHost = "example.com";
+ CreateServer();
+
+ var response = await SendRequest("/test?foo=bar&baz=qux", followRedirects: false);
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ Assert.That(response.Headers.Location.ToString(), Is.EqualTo("https://example.com/test?foo=bar&baz=qux"));
+ }
+
+ [Test]
+ public async Task Should_strip_port_from_host()
+ {
+ HttpsTestStartup.ConfigureRedirectEnabled();
+ HttpsTestStartup.SimulatedScheme = "http";
+ HttpsTestStartup.SimulatedHost = "example.com:8080";
+ CreateServer();
+
+ var response = await SendRequest("/test", followRedirects: false);
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ Assert.That(response.Headers.Location.ToString(), Is.EqualTo("https://example.com/test"));
+ }
+
+ [Test]
+ public async Task Should_use_307_temporary_redirect()
+ {
+ HttpsTestStartup.ConfigureRedirectEnabled();
+ HttpsTestStartup.SimulatedScheme = "http";
+ CreateServer();
+
+ var response = await SendRequest("/api/data", method: HttpMethod.Post, followRedirects: false);
+
+ // 307 preserves the HTTP method (important for POST, PUT, etc.)
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ }
+
+ [Test]
+ public async Task Should_handle_ipv6_host_in_redirect()
+ {
+ HttpsTestStartup.ConfigureRedirectEnabled();
+ HttpsTestStartup.SimulatedScheme = "http";
+ HttpsTestStartup.SimulatedHost = "[::1]";
+ CreateServer();
+
+ var response = await SendRequest("/test", followRedirects: false);
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ Assert.That(response.Headers.Location.ToString(), Does.StartWith("https://[::1]"));
+ }
+
+ [Test]
+ public async Task Should_strip_port_from_ipv6_host()
+ {
+ HttpsTestStartup.ConfigureRedirectEnabled();
+ HttpsTestStartup.SimulatedScheme = "http";
+ HttpsTestStartup.SimulatedHost = "[::1]:8080";
+ CreateServer();
+
+ var response = await SendRequest("/test", followRedirects: false);
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ Assert.That(response.Headers.Location.ToString(), Does.StartWith("https://[::1]"));
+ Assert.That(response.Headers.Location.ToString(), Does.Not.Contain(":8080"));
+ }
+ }
+}
diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_https_with_forwarded_headers.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_https_with_forwarded_headers.cs
new file mode 100644
index 0000000000..a1484e262e
--- /dev/null
+++ b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_https_with_forwarded_headers.cs
@@ -0,0 +1,96 @@
+namespace ServicePulse.Host.Tests.AcceptanceTests.Https
+{
+ using System.Net;
+ using System.Threading.Tasks;
+ using NUnit.Framework;
+
+ ///
+ /// Tests HTTPS middleware behavior when used with forwarded headers.
+ /// This is the reverse proxy scenario where the proxy terminates SSL
+ /// and forwards requests to the application over HTTP.
+ ///
+ [TestFixture]
+ public class When_https_with_forwarded_headers : HttpsTestBase
+ {
+ [SetUp]
+ public void SetUp()
+ {
+ HttpsTestStartup.ConfigureWithForwardedHeaders();
+ CreateServer();
+ }
+
+ [Test]
+ public async Task Should_add_hsts_when_forwarded_proto_is_https()
+ {
+ HttpsTestStartup.SimulatedScheme = "http"; // Original request is HTTP
+
+ var response = await SendRequest("/debug/https-info", forwardedProto: "https");
+ response.EnsureSuccessStatusCode();
+
+ Assert.That(response.Headers.Contains("Strict-Transport-Security"), Is.True);
+ }
+
+ [Test]
+ public async Task Should_not_redirect_when_forwarded_proto_is_https()
+ {
+ HttpsTestStartup.SimulatedScheme = "http"; // Original request is HTTP
+
+ var response = await SendRequest("/debug/https-info", forwardedProto: "https");
+
+ // Should NOT redirect because effective scheme is HTTPS
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ }
+
+ [Test]
+ public async Task Should_redirect_when_forwarded_proto_is_http()
+ {
+ HttpsTestStartup.SimulatedScheme = "http";
+
+ var response = await SendRequest("/test", forwardedProto: "http", followRedirects: false);
+
+ // Should redirect because effective scheme is HTTP
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ }
+
+ [Test]
+ public async Task Should_use_forwarded_host_in_redirect()
+ {
+ HttpsTestStartup.SimulatedScheme = "http";
+ HttpsTestStartup.SimulatedHost = "internal-server";
+
+ var response = await SendRequest("/test",
+ forwardedProto: "http",
+ forwardedHost: "public.example.com",
+ followRedirects: false);
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ // Should use the forwarded host, not the original
+ Assert.That(response.Headers.Location.ToString(), Is.EqualTo("https://public.example.com/test"));
+ }
+
+ [Test]
+ public async Task Should_not_add_hsts_when_forwarded_proto_is_http()
+ {
+ HttpsTestStartup.SimulatedScheme = "http";
+
+ var response = await SendRequest("/debug/https-info", forwardedProto: "http", followRedirects: false);
+
+ // Will redirect, but if we could check the pre-redirect response,
+ // it should not have HSTS since the effective scheme is HTTP
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ }
+
+ [TestCase("HTTPS")]
+ [TestCase("Https")]
+ [TestCase("hTtPs")]
+ public async Task Should_handle_case_insensitive_forwarded_proto(string proto)
+ {
+ HttpsTestStartup.SimulatedScheme = "http";
+
+ var response = await SendRequest("/debug/https-info", forwardedProto: proto);
+
+ // Should not redirect - HTTPS proto handling should be case-insensitive
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ }
+ }
+}
diff --git a/src/ServicePulse.Host.Tests/CommandLineTests/ForwardedHeadersArgumentsTests.cs b/src/ServicePulse.Host.Tests/CommandLineTests/ForwardedHeadersArgumentsTests.cs
new file mode 100644
index 0000000000..75bb36abb2
--- /dev/null
+++ b/src/ServicePulse.Host.Tests/CommandLineTests/ForwardedHeadersArgumentsTests.cs
@@ -0,0 +1,183 @@
+namespace ServicePulse.Host.Tests.CommandLineTests
+{
+ using System;
+ using NUnit.Framework;
+ using ServicePulse.Host.Hosting;
+
+ [TestFixture]
+ public class ForwardedHeadersArgumentsTests
+ {
+ [Test]
+ public void ForwardedHeadersEnabled_Default_Is_True()
+ {
+ var hostArgs = new HostArguments(Array.Empty());
+ Assert.That(hostArgs.ForwardedHeadersEnabled, Is.True);
+ }
+
+ [Test]
+ public void ForwardedHeadersEnabled_Can_Be_Set_To_False()
+ {
+ var args = new[] { "--forwardedheadersenabled=false" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.ForwardedHeadersEnabled, Is.False);
+ }
+
+ [Test]
+ public void ForwardedHeadersEnabled_Can_Be_Set_To_True()
+ {
+ var args = new[] { "--forwardedheadersenabled=true" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.ForwardedHeadersEnabled, Is.True);
+ }
+
+ [Test]
+ public void ForwardedHeadersEnabled_Invalid_Value_Uses_Default()
+ {
+ var args = new[] { "--forwardedheadersenabled=invalid" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.ForwardedHeadersEnabled, Is.True);
+ }
+
+ [Test]
+ public void ForwardedHeadersTrustAllProxies_Default_Is_True()
+ {
+ var hostArgs = new HostArguments(Array.Empty());
+ Assert.That(hostArgs.ForwardedHeadersTrustAllProxies, Is.True);
+ }
+
+ [Test]
+ public void ForwardedHeadersTrustAllProxies_Can_Be_Set_To_False()
+ {
+ var args = new[] { "--forwardedheaderstrustallproxies=false" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.ForwardedHeadersTrustAllProxies, Is.False);
+ }
+
+ [Test]
+ public void ForwardedHeadersTrustAllProxies_Can_Be_Set_To_True()
+ {
+ var args = new[] { "--forwardedheaderstrustallproxies=true" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.ForwardedHeadersTrustAllProxies, Is.True);
+ }
+
+ [Test]
+ public void ForwardedHeadersKnownProxies_Default_Is_Empty()
+ {
+ var hostArgs = new HostArguments(Array.Empty());
+ Assert.That(hostArgs.ForwardedHeadersKnownProxies, Is.Empty);
+ }
+
+ [Test]
+ public void ForwardedHeadersKnownProxies_Can_Be_Set_Single_IP()
+ {
+ var args = new[] { "--forwardedheadersknownproxies=192.168.1.1" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.ForwardedHeadersKnownProxies, Has.Count.EqualTo(1));
+ Assert.That(hostArgs.ForwardedHeadersKnownProxies[0], Is.EqualTo("192.168.1.1"));
+ }
+
+ [Test]
+ public void ForwardedHeadersKnownProxies_Can_Be_Set_Multiple_IPs_Comma_Separated()
+ {
+ var args = new[] { "--forwardedheadersknownproxies=192.168.1.1,10.0.0.1,172.16.0.1" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.ForwardedHeadersKnownProxies, Has.Count.EqualTo(3));
+ Assert.That(hostArgs.ForwardedHeadersKnownProxies[0], Is.EqualTo("192.168.1.1"));
+ Assert.That(hostArgs.ForwardedHeadersKnownProxies[1], Is.EqualTo("10.0.0.1"));
+ Assert.That(hostArgs.ForwardedHeadersKnownProxies[2], Is.EqualTo("172.16.0.1"));
+ }
+
+ [Test]
+ public void ForwardedHeadersKnownProxies_Can_Be_Set_Multiple_IPs_Semicolon_Separated()
+ {
+ var args = new[] { "--forwardedheadersknownproxies=192.168.1.1;10.0.0.1" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.ForwardedHeadersKnownProxies, Has.Count.EqualTo(2));
+ Assert.That(hostArgs.ForwardedHeadersKnownProxies[0], Is.EqualTo("192.168.1.1"));
+ Assert.That(hostArgs.ForwardedHeadersKnownProxies[1], Is.EqualTo("10.0.0.1"));
+ }
+
+ [Test]
+ public void ForwardedHeadersKnownProxies_Trims_Whitespace()
+ {
+ var args = new[] { "--forwardedheadersknownproxies= 192.168.1.1 , 10.0.0.1 " };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.ForwardedHeadersKnownProxies, Has.Count.EqualTo(2));
+ Assert.That(hostArgs.ForwardedHeadersKnownProxies[0], Is.EqualTo("192.168.1.1"));
+ Assert.That(hostArgs.ForwardedHeadersKnownProxies[1], Is.EqualTo("10.0.0.1"));
+ }
+
+ [Test]
+ public void ForwardedHeadersKnownProxies_Empty_String_Returns_Empty_List()
+ {
+ var args = new[] { "--forwardedheadersknownproxies=" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.ForwardedHeadersKnownProxies, Is.Empty);
+ }
+
+ [Test]
+ public void ForwardedHeadersKnownNetworks_Default_Is_Empty()
+ {
+ var hostArgs = new HostArguments(Array.Empty());
+ Assert.That(hostArgs.ForwardedHeadersKnownNetworks, Is.Empty);
+ }
+
+ [Test]
+ public void ForwardedHeadersKnownNetworks_Can_Be_Set_Single_CIDR()
+ {
+ var args = new[] { "--forwardedheadersknownnetworks=192.168.1.0/24" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.ForwardedHeadersKnownNetworks, Has.Count.EqualTo(1));
+ Assert.That(hostArgs.ForwardedHeadersKnownNetworks[0], Is.EqualTo("192.168.1.0/24"));
+ }
+
+ [Test]
+ public void ForwardedHeadersKnownNetworks_Can_Be_Set_Multiple_CIDRs()
+ {
+ var args = new[] { "--forwardedheadersknownnetworks=192.168.1.0/24,10.0.0.0/8" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.ForwardedHeadersKnownNetworks, Has.Count.EqualTo(2));
+ Assert.That(hostArgs.ForwardedHeadersKnownNetworks[0], Is.EqualTo("192.168.1.0/24"));
+ Assert.That(hostArgs.ForwardedHeadersKnownNetworks[1], Is.EqualTo("10.0.0.0/8"));
+ }
+
+ [Test]
+ public void ForwardedHeaders_All_Options_Can_Be_Combined()
+ {
+ var args = new[]
+ {
+ "--forwardedheadersenabled=true",
+ "--forwardedheaderstrustallproxies=false",
+ "--forwardedheadersknownproxies=192.168.1.1,10.0.0.1",
+ "--forwardedheadersknownnetworks=192.168.0.0/16"
+ };
+ var hostArgs = new HostArguments(args);
+
+ Assert.That(hostArgs.ForwardedHeadersEnabled, Is.True);
+ Assert.That(hostArgs.ForwardedHeadersTrustAllProxies, Is.False);
+ Assert.That(hostArgs.ForwardedHeadersKnownProxies, Has.Count.EqualTo(2));
+ Assert.That(hostArgs.ForwardedHeadersKnownNetworks, Has.Count.EqualTo(1));
+ }
+
+ [TestCase("--FORWARDEDHEADERSENABLED=false")]
+ [TestCase("--ForwardedHeadersEnabled=false")]
+ [TestCase("--forwardedheadersenabled=false")]
+ public void ForwardedHeaders_Argument_Names_Are_Case_Insensitive(string arg)
+ {
+ var args = new[] { arg };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.ForwardedHeadersEnabled, Is.False);
+ }
+
+ [TestCase("FALSE")]
+ [TestCase("False")]
+ [TestCase("false")]
+ public void ForwardedHeadersEnabled_Values_Are_Case_Insensitive(string value)
+ {
+ var args = new[] { $"--forwardedheadersenabled={value}" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.ForwardedHeadersEnabled, Is.False);
+ }
+ }
+}
diff --git a/src/ServicePulse.Host.Tests/CommandLineTests/HttpsArgumentsTests.cs b/src/ServicePulse.Host.Tests/CommandLineTests/HttpsArgumentsTests.cs
new file mode 100644
index 0000000000..92dd8eefbe
--- /dev/null
+++ b/src/ServicePulse.Host.Tests/CommandLineTests/HttpsArgumentsTests.cs
@@ -0,0 +1,190 @@
+namespace ServicePulse.Host.Tests.CommandLineTests
+{
+ using System;
+ using NUnit.Framework;
+ using ServicePulse.Host.Hosting;
+
+ [TestFixture]
+ public class HttpsArgumentsTests
+ {
+ [Test]
+ public void HttpsEnabled_Default_Is_False()
+ {
+ var hostArgs = new HostArguments(Array.Empty());
+ Assert.That(hostArgs.HttpsEnabled, Is.False);
+ }
+
+ [Test]
+ public void HttpsEnabled_Can_Be_Set_To_True()
+ {
+ var args = new[] { "--httpsenabled=true" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.HttpsEnabled, Is.True);
+ }
+
+ [Test]
+ public void HttpsEnabled_Can_Be_Set_To_False()
+ {
+ var args = new[] { "--httpsenabled=false" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.HttpsEnabled, Is.False);
+ }
+
+ [Test]
+ public void HttpsEnabled_Invalid_Value_Uses_Default()
+ {
+ var args = new[] { "--httpsenabled=invalid" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.HttpsEnabled, Is.False);
+ }
+
+ [Test]
+ public void HttpsRedirectHttpToHttps_Default_Is_False()
+ {
+ var hostArgs = new HostArguments(Array.Empty());
+ Assert.That(hostArgs.HttpsRedirectHttpToHttps, Is.False);
+ }
+
+ [Test]
+ public void HttpsRedirectHttpToHttps_Can_Be_Set_To_True()
+ {
+ var args = new[] { "--httpsredirecthttptohttps=true" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.HttpsRedirectHttpToHttps, Is.True);
+ }
+
+ [Test]
+ public void HttpsRedirectHttpToHttps_Can_Be_Set_To_False()
+ {
+ var args = new[] { "--httpsredirecthttptohttps=false" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.HttpsRedirectHttpToHttps, Is.False);
+ }
+
+ [Test]
+ public void HttpsPort_Default_Is_Null()
+ {
+ var hostArgs = new HostArguments(Array.Empty());
+ Assert.That(hostArgs.HttpsPort, Is.Null);
+ }
+
+ [Test]
+ public void HttpsPort_Can_Be_Set()
+ {
+ var args = new[] { "--httpsport=443" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.HttpsPort, Is.EqualTo(443));
+ }
+
+ [Test]
+ public void HttpsPort_Custom_Value()
+ {
+ var args = new[] { "--httpsport=8443" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.HttpsPort, Is.EqualTo(8443));
+ }
+
+ [Test]
+ public void HttpsPort_Invalid_Value_Returns_Null()
+ {
+ var args = new[] { "--httpsport=invalid" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.HttpsPort, Is.Null);
+ }
+
+ [Test]
+ public void HttpsEnableHsts_Default_Is_False()
+ {
+ var hostArgs = new HostArguments(Array.Empty());
+ Assert.That(hostArgs.HttpsEnableHsts, Is.False);
+ }
+
+ [Test]
+ public void HttpsEnableHsts_Can_Be_Set_To_True()
+ {
+ var args = new[] { "--httpsenablehsts=true" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.HttpsEnableHsts, Is.True);
+ }
+
+ [Test]
+ public void HttpsHstsMaxAgeSeconds_Default_Is_OneYear()
+ {
+ var hostArgs = new HostArguments(Array.Empty());
+ Assert.That(hostArgs.HttpsHstsMaxAgeSeconds, Is.EqualTo(31536000));
+ }
+
+ [Test]
+ public void HttpsHstsMaxAgeSeconds_Can_Be_Set()
+ {
+ var args = new[] { "--httpshstsmaxageseconds=86400" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.HttpsHstsMaxAgeSeconds, Is.EqualTo(86400));
+ }
+
+ [Test]
+ public void HttpsHstsMaxAgeSeconds_Invalid_Value_Uses_Default()
+ {
+ var args = new[] { "--httpshstsmaxageseconds=invalid" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.HttpsHstsMaxAgeSeconds, Is.EqualTo(31536000));
+ }
+
+ [Test]
+ public void HttpsHstsIncludeSubDomains_Default_Is_False()
+ {
+ var hostArgs = new HostArguments(Array.Empty());
+ Assert.That(hostArgs.HttpsHstsIncludeSubDomains, Is.False);
+ }
+
+ [Test]
+ public void HttpsHstsIncludeSubDomains_Can_Be_Set_To_True()
+ {
+ var args = new[] { "--httpshstsincludesubdomains=true" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.HttpsHstsIncludeSubDomains, Is.True);
+ }
+
+ [Test]
+ public void Https_All_Options_Can_Be_Combined()
+ {
+ var args = new[]
+ {
+ "--httpsenabled=true",
+ "--httpsredirecthttptohttps=true",
+ "--httpsport=443",
+ "--httpsenablehsts=true",
+ "--httpshstsmaxageseconds=86400",
+ "--httpshstsincludesubdomains=true"
+ };
+ var hostArgs = new HostArguments(args);
+
+ Assert.That(hostArgs.HttpsEnabled, Is.True);
+ Assert.That(hostArgs.HttpsRedirectHttpToHttps, Is.True);
+ Assert.That(hostArgs.HttpsPort, Is.EqualTo(443));
+ Assert.That(hostArgs.HttpsEnableHsts, Is.True);
+ Assert.That(hostArgs.HttpsHstsMaxAgeSeconds, Is.EqualTo(86400));
+ Assert.That(hostArgs.HttpsHstsIncludeSubDomains, Is.True);
+ }
+
+ [TestCase("TRUE")]
+ [TestCase("True")]
+ [TestCase("true")]
+ public void HttpsEnabled_Is_Case_Insensitive(string value)
+ {
+ var args = new[] { $"--httpsenabled={value}" };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.HttpsEnabled, Is.True);
+ }
+
+ [TestCase("--HTTPSENABLED=true")]
+ [TestCase("--HttpsEnabled=true")]
+ [TestCase("--httpsenabled=true")]
+ public void Https_Argument_Names_Are_Case_Insensitive(string arg)
+ {
+ var args = new[] { arg };
+ var hostArgs = new HostArguments(args);
+ Assert.That(hostArgs.HttpsEnabled, Is.True);
+ }
+ }
+}
diff --git a/src/ServicePulse.Host.Tests/ServicePulse.Host.Tests.csproj b/src/ServicePulse.Host.Tests/ServicePulse.Host.Tests.csproj
index 6f6f046605..b94d53c8ae 100644
--- a/src/ServicePulse.Host.Tests/ServicePulse.Host.Tests.csproj
+++ b/src/ServicePulse.Host.Tests/ServicePulse.Host.Tests.csproj
@@ -11,10 +11,11 @@
+
-
+
diff --git a/src/ServicePulse.Host/Hosting/Host.cs b/src/ServicePulse.Host/Hosting/Host.cs
index 73ffb7fd09..e102963455 100644
--- a/src/ServicePulse.Host/Hosting/Host.cs
+++ b/src/ServicePulse.Host/Hosting/Host.cs
@@ -1,6 +1,7 @@
-namespace ServicePulse.Host.Hosting
+namespace ServicePulse.Host.Hosting
{
using System;
+ using System.Net;
using System.ServiceProcess;
using Microsoft.Owin.Hosting;
using ServicePulse.Host.Owin;
@@ -25,10 +26,75 @@ public void Run()
protected override void OnStart(string[] args)
{
+ ConfigureForwardedHeaders();
+ ConfigureHttps();
+
+#if DEBUG
+ // Enable debug endpoint when running interactively in debug builds
+ OwinBootstrapper.EnableDebugEndpoint = Environment.UserInteractive;
+#endif
+
var hostingUrl = UrlHelper.RewriteLocalhostUrl(arguments.Url);
owinHost = WebApp.Start(hostingUrl);
}
+ void ConfigureForwardedHeaders()
+ {
+ var options = new ForwardedHeadersOptions
+ {
+ Enabled = arguments.ForwardedHeadersEnabled,
+ TrustAllProxies = arguments.ForwardedHeadersTrustAllProxies
+ };
+
+ // Parse known proxies
+ foreach (var proxyString in arguments.ForwardedHeadersKnownProxies)
+ {
+ if (IPAddress.TryParse(proxyString, out var proxy))
+ {
+ options.KnownProxies.Add(proxy);
+ }
+ }
+
+ // Parse known networks
+ foreach (var networkString in arguments.ForwardedHeadersKnownNetworks)
+ {
+ if (CidrNetwork.TryParse(networkString, out var network))
+ {
+ options.KnownNetworks.Add(network);
+ }
+ }
+
+ // If specific proxies or networks are configured, disable trust all proxies
+ if (options.KnownProxies.Count > 0 || options.KnownNetworks.Count > 0)
+ {
+ options.TrustAllProxies = false;
+ }
+
+ // Set ForwardLimit based on TrustAllProxies (align with ASP.NET Core behavior)
+ if (options.TrustAllProxies)
+ {
+ options.ForwardLimit = null; // No limit - process all entries
+ }
+ // else ForwardLimit defaults to 1
+
+ OwinBootstrapper.ForwardedHeadersOptions = options;
+ }
+
+ void ConfigureHttps()
+ {
+ var options = new HttpsOptions
+ {
+ Enabled = arguments.HttpsEnabled,
+ RedirectHttpToHttps = arguments.HttpsRedirectHttpToHttps,
+ Port = arguments.HttpsPort,
+ EnableHsts = arguments.HttpsEnableHsts,
+ HstsMaxAgeSeconds = arguments.HttpsHstsMaxAgeSeconds,
+ HstsIncludeSubDomains = arguments.HttpsHstsIncludeSubDomains
+ };
+
+ OwinBootstrapper.HttpsOptions = options;
+ }
+
protected override void OnStop()
{
owinHost.Dispose();
diff --git a/src/ServicePulse.Host/Hosting/HostArguments.cs b/src/ServicePulse.Host/Hosting/HostArguments.cs
index 563157bc1c..0a7dc0d000 100644
--- a/src/ServicePulse.Host/Hosting/HostArguments.cs
+++ b/src/ServicePulse.Host/Hosting/HostArguments.cs
@@ -38,6 +38,56 @@ public HostArguments(string[] args)
"url=",
@"Configures ServicePulse to listen on the specified url.",
s => { Url = s; }
+ },
+ {
+ "forwardedheadersenabled=",
+ @"Enable processing of forwarded headers (default: true).",
+ s => { ForwardedHeadersEnabled = ParseBool(s, true); }
+ },
+ {
+ "forwardedheaderstrustallproxies=",
+ @"Trust all proxies for forwarded headers (default: true).",
+ s => { ForwardedHeadersTrustAllProxies = ParseBool(s, true); }
+ },
+ {
+ "forwardedheadersknownproxies=",
+ @"Comma-separated list of trusted proxy IP addresses.",
+ s => { ForwardedHeadersKnownProxies = ParseList(s); }
+ },
+ {
+ "forwardedheadersknownnetworks=",
+ @"Comma-separated list of trusted proxy networks in CIDR notation.",
+ s => { ForwardedHeadersKnownNetworks = ParseList(s); }
+ },
+ {
+ "httpsenabled=",
+ @"Enable HTTPS features (default: false). Note: SSL certificate must be bound at OS level using netsh.",
+ s => { HttpsEnabled = ParseBool(s, false); }
+ },
+ {
+ "httpsredirecthttptohttps=",
+ @"Redirect HTTP requests to HTTPS (default: false).",
+ s => { HttpsRedirectHttpToHttps = ParseBool(s, false); }
+ },
+ {
+ "httpsport=",
+ @"HTTPS port for redirect (required for reverse proxy scenarios).",
+ s => { HttpsPort = ParseNullableInt(s); }
+ },
+ {
+ "httpsenablehsts=",
+ @"Enable HTTP Strict Transport Security (default: false).",
+ s => { HttpsEnableHsts = ParseBool(s, false); }
+ },
+ {
+ "httpshstsmaxageseconds=",
+ @"HSTS max age in seconds (default: 31536000 = 1 year).",
+ s => { HttpsHstsMaxAgeSeconds = ParseInt(s, 31536000); }
+ },
+ {
+ "httpshstsincludesubdomains=",
+ @"Include subdomains in HSTS policy (default: false).",
+ s => { HttpsHstsIncludeSubDomains = ParseBool(s, false); }
}
};
@@ -232,6 +282,46 @@ void ThrowIfUnknownArgs(List unknownArgsList)
}
}
+ static bool ParseBool(string value, bool defaultValue)
+ {
+ if (bool.TryParse(value, out var result))
+ {
+ return result;
+ }
+ return defaultValue;
+ }
+
+ static int ParseInt(string value, int defaultValue)
+ {
+ if (int.TryParse(value, out var result))
+ {
+ return result;
+ }
+ return defaultValue;
+ }
+
+ static int? ParseNullableInt(string value)
+ {
+ if (int.TryParse(value, out var result))
+ {
+ return result;
+ }
+ return null;
+ }
+
+ static List ParseList(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return new List();
+ }
+
+ return value.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(s => s.Trim())
+ .Where(s => !string.IsNullOrEmpty(s))
+ .ToList();
+ }
+
void ValidateArgs()
{
var validProtocols = new[]
@@ -289,6 +379,20 @@ void ValidateArgs()
public string ServiceControlMonitoringUrl { get; set; }
+ // Forwarded Headers settings
+ public bool ForwardedHeadersEnabled { get; set; } = true;
+ public bool ForwardedHeadersTrustAllProxies { get; set; } = true;
+ public List ForwardedHeadersKnownProxies { get; set; } = new List();
+ public List ForwardedHeadersKnownNetworks { get; set; } = new List();
+
+ // HTTPS settings (certificate is bound at OS level via netsh, not in app)
+ public bool HttpsEnabled { get; set; } = false;
+ public bool HttpsRedirectHttpToHttps { get; set; } = false;
+ public int? HttpsPort { get; set; } = null;
+ public bool HttpsEnableHsts { get; set; } = false;
+ public int HttpsHstsMaxAgeSeconds { get; set; } = 31536000;
+ public bool HttpsHstsIncludeSubDomains { get; set; } = false;
+
public string DisplayName { get; set; }
public string Description { get; set; }
diff --git a/src/ServicePulse.Host/Owin/DebugRequestInfoMiddleware.cs b/src/ServicePulse.Host/Owin/DebugRequestInfoMiddleware.cs
new file mode 100644
index 0000000000..83ac800a63
--- /dev/null
+++ b/src/ServicePulse.Host/Owin/DebugRequestInfoMiddleware.cs
@@ -0,0 +1,86 @@
+namespace ServicePulse.Host.Owin
+{
+ using System;
+ using System.Linq;
+ using System.Threading.Tasks;
+ using global::Microsoft.Owin;
+ using global::Owin;
+
+ public class DebugRequestInfoMiddleware : OwinMiddleware
+ {
+ readonly ForwardedHeadersOptions options;
+
+ public DebugRequestInfoMiddleware(OwinMiddleware next, ForwardedHeadersOptions options) : base(next)
+ {
+ this.options = options;
+ }
+
+ public override async Task Invoke(IOwinContext context)
+ {
+ if (context.Request.Path.ToString().Equals("/debug/request-info", StringComparison.OrdinalIgnoreCase))
+ {
+ var response = context.Response;
+ response.ContentType = "application/json";
+
+ // Check for forwarded values in environment, fall back to request properties
+ var remoteIpAddress = context.Environment.TryGetValue("server.RemoteIpAddress", out var forwardedIp)
+ ? forwardedIp?.ToString() ?? context.Request.RemoteIpAddress ?? "unknown"
+ : context.Request.RemoteIpAddress ?? "unknown";
+
+ var scheme = context.Environment.TryGetValue("owin.RequestScheme", out var forwardedScheme)
+ ? forwardedScheme?.ToString() ?? context.Request.Scheme ?? "unknown"
+ : context.Request.Scheme ?? "unknown";
+
+ var host = context.Environment.TryGetValue("host.RequestHost", out var forwardedHost)
+ ? forwardedHost?.ToString() ?? context.Request.Host.ToString()
+ : context.Request.Host.ToString();
+
+ // Raw headers (what remains after middleware processing)
+ var xForwardedFor = context.Request.Headers.Get("X-Forwarded-For") ?? "";
+ var xForwardedProto = context.Request.Headers.Get("X-Forwarded-Proto") ?? "";
+ var xForwardedHost = context.Request.Headers.Get("X-Forwarded-Host") ?? "";
+
+ // Configuration
+ var knownProxies = string.Join(", ", options.KnownProxies.Select(p => $"\"{p}\""));
+ var knownNetworks = string.Join(", ", options.KnownNetworks.Select(n => $"\"{n.BaseAddress}/{n.PrefixLength}\""));
+
+ var json = $@"{{
+ ""processed"": {{
+ ""scheme"": ""{scheme}"",
+ ""host"": ""{host}"",
+ ""remoteIpAddress"": ""{remoteIpAddress}""
+ }},
+ ""rawHeaders"": {{
+ ""xForwardedFor"": ""{xForwardedFor}"",
+ ""xForwardedProto"": ""{xForwardedProto}"",
+ ""xForwardedHost"": ""{xForwardedHost}""
+ }},
+ ""configuration"": {{
+ ""enabled"": {options.Enabled.ToString().ToLowerInvariant()},
+ ""trustAllProxies"": {options.TrustAllProxies.ToString().ToLowerInvariant()},
+ ""knownProxies"": [{knownProxies}],
+ ""knownNetworks"": [{knownNetworks}]
+ }}
+}}";
+
+ await response.WriteAsync(json).ConfigureAwait(false);
+ return;
+ }
+
+ await Next.Invoke(context).ConfigureAwait(false);
+ }
+ }
+
+ public static class DebugRequestInfoExtensions
+ {
+ public static IAppBuilder UseDebugRequestInfo(this IAppBuilder builder, ForwardedHeadersOptions options)
+ {
+ if (builder == null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+
+ return builder.Use(options);
+ }
+ }
+}
diff --git a/src/ServicePulse.Host/Owin/ForwardedHeadersMiddleware.cs b/src/ServicePulse.Host/Owin/ForwardedHeadersMiddleware.cs
new file mode 100644
index 0000000000..56c8ee36d3
--- /dev/null
+++ b/src/ServicePulse.Host/Owin/ForwardedHeadersMiddleware.cs
@@ -0,0 +1,316 @@
+namespace ServicePulse.Host.Owin
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+ using System.Net;
+ using System.Threading.Tasks;
+ using global::Microsoft.Owin;
+ using global::Owin;
+
+ public class ForwardedHeadersMiddleware : OwinMiddleware
+ {
+ readonly ForwardedHeadersOptions options;
+
+ public ForwardedHeadersMiddleware(OwinMiddleware next, ForwardedHeadersOptions options) : base(next)
+ {
+ this.options = options;
+ }
+
+ public override Task Invoke(IOwinContext context)
+ {
+ if (!options.Enabled)
+ {
+ return Next.Invoke(context);
+ }
+
+ var request = context.Request;
+
+ if (!IsTrustedProxy(request.RemoteIpAddress))
+ {
+ return Next.Invoke(context);
+ }
+
+ // Process X-Forwarded-Proto (take leftmost value - original client's scheme)
+ var forwardedProto = request.Headers.Get("X-Forwarded-Proto");
+ if (!string.IsNullOrEmpty(forwardedProto))
+ {
+ var values = forwardedProto.Split(',').Select(v => v.Trim()).ToList();
+ var scheme = values[0];
+ context.Environment["owin.RequestScheme"] = scheme;
+
+ // Consume the header - when TrustAllProxies, consume all values (consistent with ASP.NET Core)
+ if (options.TrustAllProxies)
+ {
+ request.Headers.Remove("X-Forwarded-Proto");
+ }
+ else
+ {
+ values.RemoveAt(0);
+ if (values.Count > 0)
+ {
+ request.Headers.Set("X-Forwarded-Proto", string.Join(", ", values));
+ }
+ else
+ {
+ request.Headers.Remove("X-Forwarded-Proto");
+ }
+ }
+ }
+
+ // Process X-Forwarded-Host (take leftmost value - original client's host)
+ var forwardedHost = request.Headers.Get("X-Forwarded-Host");
+ if (!string.IsNullOrEmpty(forwardedHost))
+ {
+ var values = forwardedHost.Split(',').Select(v => v.Trim()).ToList();
+ var host = values[0];
+ context.Environment["host.RequestHost"] = host;
+ request.Headers.Set("Host", host);
+
+ // Consume the header - when TrustAllProxies, consume all values (consistent with ASP.NET Core)
+ if (options.TrustAllProxies)
+ {
+ request.Headers.Remove("X-Forwarded-Host");
+ }
+ else
+ {
+ values.RemoveAt(0);
+ if (values.Count > 0)
+ {
+ request.Headers.Set("X-Forwarded-Host", string.Join(", ", values));
+ }
+ else
+ {
+ request.Headers.Remove("X-Forwarded-Host");
+ }
+ }
+ }
+
+ // Process X-Forwarded-For (right-to-left with ForwardLimit, consume processed entries)
+ var forwardedFor = request.Headers.Get("X-Forwarded-For");
+ if (!string.IsNullOrEmpty(forwardedFor))
+ {
+ var entries = forwardedFor.Split(',').Select(v => v.Trim()).ToList();
+ var entriesProcessed = 0;
+ // When TrustAllProxies is enabled, ignore ForwardLimit (consistent with ASP.NET Core)
+ var limit = options.TrustAllProxies ? int.MaxValue : (options.ForwardLimit ?? int.MaxValue);
+
+ // Process from right to left
+ while (entries.Count > 0 && entriesProcessed < limit)
+ {
+ var currentEntry = entries[entries.Count - 1];
+ entries.RemoveAt(entries.Count - 1);
+ entriesProcessed++;
+
+ // Strip port from IP address if present (e.g., "192.168.1.1:8080" or "[::1]:8080")
+ var currentIp = StripPort(currentEntry);
+ context.Environment["server.RemoteIpAddress"] = currentIp;
+
+ // If there are more entries, check if we should continue
+ if (entries.Count > 0 && entriesProcessed < limit)
+ {
+ // For TrustAllProxies, continue processing all
+ // For known proxies/networks, check if current IP is trusted
+ if (!options.TrustAllProxies && !IsTrustedIp(currentIp))
+ {
+ break;
+ }
+ }
+ }
+
+ // Update header with remaining entries
+ if (entries.Count > 0)
+ {
+ request.Headers.Set("X-Forwarded-For", string.Join(", ", entries));
+ }
+ else
+ {
+ request.Headers.Remove("X-Forwarded-For");
+ }
+ }
+
+ return Next.Invoke(context);
+ }
+
+ static string StripPort(string ipAddress)
+ {
+ if (string.IsNullOrEmpty(ipAddress))
+ {
+ return ipAddress;
+ }
+
+ // IPv6 addresses are enclosed in brackets: [::1] or [::1]:8080
+ if (ipAddress.StartsWith("["))
+ {
+ var closingBracket = ipAddress.IndexOf(']');
+ if (closingBracket > 0)
+ {
+ // Return just the IPv6 address without brackets or port
+ return ipAddress.Substring(1, closingBracket - 1);
+ }
+ // Malformed, return as-is
+ return ipAddress;
+ }
+
+ // IPv4 or hostname: look for port separator
+ var colonIndex = ipAddress.IndexOf(':');
+ return colonIndex > 0 ? ipAddress.Substring(0, colonIndex) : ipAddress;
+ }
+
+ bool IsTrustedProxy(string remoteIpAddress)
+ {
+ if (options.TrustAllProxies)
+ {
+ return true;
+ }
+
+ return IsTrustedIp(remoteIpAddress);
+ }
+
+ bool IsTrustedIp(string ipAddress)
+ {
+ if (string.IsNullOrEmpty(ipAddress))
+ {
+ return false;
+ }
+
+ if (!IPAddress.TryParse(ipAddress, out var ip))
+ {
+ return false;
+ }
+
+ // Normalize IPv4-mapped IPv6 addresses (::ffff:192.168.1.1) to IPv4
+ if (ip.IsIPv4MappedToIPv6)
+ {
+ ip = ip.MapToIPv4();
+ }
+
+ // Check known proxies (also normalizing for comparison)
+ foreach (var proxy in options.KnownProxies)
+ {
+ var normalizedProxy = proxy.IsIPv4MappedToIPv6 ? proxy.MapToIPv4() : proxy;
+ if (normalizedProxy.Equals(ip))
+ {
+ return true;
+ }
+ }
+
+ // Check known networks
+ foreach (var network in options.KnownNetworks)
+ {
+ if (network.Contains(ip))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+
+ public class ForwardedHeadersOptions
+ {
+ public bool Enabled { get; set; } = true;
+ public bool TrustAllProxies { get; set; } = true;
+ public int? ForwardLimit { get; set; } = 1;
+ public List KnownProxies { get; set; } = new List();
+ public List KnownNetworks { get; set; } = new List();
+ }
+
+ public class CidrNetwork
+ {
+ public IPAddress BaseAddress { get; }
+ public int PrefixLength { get; }
+
+ public CidrNetwork(IPAddress baseAddress, int prefixLength)
+ {
+ BaseAddress = baseAddress;
+ PrefixLength = prefixLength;
+ }
+
+ public static bool TryParse(string value, out CidrNetwork network)
+ {
+ network = null;
+
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return false;
+ }
+
+ var parts = value.Split('/');
+ if (parts.Length != 2)
+ {
+ return false;
+ }
+
+ if (!IPAddress.TryParse(parts[0], out var address))
+ {
+ return false;
+ }
+
+ if (!int.TryParse(parts[1], out var prefixLength))
+ {
+ return false;
+ }
+
+ if (prefixLength < 0 || prefixLength > (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128))
+ {
+ return false;
+ }
+
+ network = new CidrNetwork(address, prefixLength);
+ return true;
+ }
+
+ public bool Contains(IPAddress address)
+ {
+ if (address.AddressFamily != BaseAddress.AddressFamily)
+ {
+ return false;
+ }
+
+ var baseBytes = BaseAddress.GetAddressBytes();
+ var addressBytes = address.GetAddressBytes();
+
+ var wholeBytes = PrefixLength / 8;
+ var remainingBits = PrefixLength % 8;
+
+ for (var i = 0; i < wholeBytes; i++)
+ {
+ if (baseBytes[i] != addressBytes[i])
+ {
+ return false;
+ }
+ }
+
+ if (remainingBits > 0 && wholeBytes < baseBytes.Length)
+ {
+ var mask = (byte)(0xFF << (8 - remainingBits));
+ if ((baseBytes[wholeBytes] & mask) != (addressBytes[wholeBytes] & mask))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+ }
+
+ public static class ForwardedHeadersExtensions
+ {
+ public static IAppBuilder UseForwardedHeaders(this IAppBuilder builder, ForwardedHeadersOptions options)
+ {
+ if (builder == null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+
+ if (options == null)
+ {
+ throw new ArgumentNullException(nameof(options));
+ }
+
+ return builder.Use(options);
+ }
+ }
+}
diff --git a/src/ServicePulse.Host/Owin/HttpsMiddleware.cs b/src/ServicePulse.Host/Owin/HttpsMiddleware.cs
new file mode 100644
index 0000000000..9101edaf09
--- /dev/null
+++ b/src/ServicePulse.Host/Owin/HttpsMiddleware.cs
@@ -0,0 +1,143 @@
+namespace ServicePulse.Host.Owin
+{
+ using System;
+ using System.Threading.Tasks;
+ using global::Microsoft.Owin;
+ using global::Owin;
+
+ public class HttpsMiddleware : OwinMiddleware
+ {
+ readonly HttpsOptions options;
+
+ public HttpsMiddleware(OwinMiddleware next, HttpsOptions options) : base(next)
+ {
+ this.options = options;
+ }
+
+ public override async Task Invoke(IOwinContext context)
+ {
+ // Skip if no HTTPS features are enabled
+ // Enabled=true activates all features (direct HTTPS scenario)
+ // Individual feature flags also activate the middleware (reverse proxy scenario)
+ if (!options.Enabled && !options.EnableHsts && !options.RedirectHttpToHttps)
+ {
+ await Next.Invoke(context).ConfigureAwait(false);
+ return;
+ }
+
+ var request = context.Request;
+ var response = context.Response;
+
+ // Get the effective scheme (may have been set by ForwardedHeadersMiddleware)
+ var scheme = context.Environment.TryGetValue("owin.RequestScheme", out var envScheme)
+ ? envScheme?.ToString() ?? request.Scheme
+ : request.Scheme;
+
+ // Add HSTS header for HTTPS requests
+ if (options.EnableHsts && string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase))
+ {
+ var hstsValue = $"max-age={options.HstsMaxAgeSeconds}";
+ if (options.HstsIncludeSubDomains)
+ {
+ hstsValue += "; includeSubDomains";
+ }
+ response.Headers.Set("Strict-Transport-Security", hstsValue);
+ }
+
+ // Redirect HTTP to HTTPS
+ if (options.RedirectHttpToHttps && string.Equals(scheme, "http", StringComparison.OrdinalIgnoreCase))
+ {
+ // Get the effective host (may have been set by ForwardedHeadersMiddleware)
+ var host = context.Environment.TryGetValue("host.RequestHost", out var envHost)
+ ? envHost?.ToString() ?? request.Host.ToString()
+ : request.Host.ToString();
+
+ // Skip redirect if host is empty or null (malformed request)
+ if (string.IsNullOrEmpty(host))
+ {
+ await Next.Invoke(context).ConfigureAwait(false);
+ return;
+ }
+
+ // Remove port from host if present, we'll add the HTTPS port
+ // IPv6 addresses are enclosed in brackets (e.g., [::1]:8080), so we need to handle them specially
+ var hostWithoutPort = GetHostWithoutPort(host);
+
+ // Build the HTTPS URL with optional port
+ string httpsUrl;
+ if (options.Port.HasValue && options.Port.Value != 443)
+ {
+ httpsUrl = $"https://{hostWithoutPort}:{options.Port.Value}{request.PathBase}{request.Path}{request.QueryString}";
+ }
+ else
+ {
+ httpsUrl = $"https://{hostWithoutPort}{request.PathBase}{request.Path}{request.QueryString}";
+ }
+
+ response.StatusCode = 307; // Temporary redirect (preserves method)
+ response.Headers.Set("Location", httpsUrl);
+ return;
+ }
+
+ await Next.Invoke(context).ConfigureAwait(false);
+ }
+
+ static string GetHostWithoutPort(string host)
+ {
+ if (string.IsNullOrEmpty(host))
+ {
+ return host;
+ }
+
+ // IPv6 addresses are enclosed in brackets: [::1] or [::1]:8080
+ if (host.StartsWith("["))
+ {
+ var closingBracket = host.IndexOf(']');
+ if (closingBracket > 0)
+ {
+ // Return just the bracketed IPv6 address without any port
+ return host.Substring(0, closingBracket + 1);
+ }
+ // Malformed IPv6, return as-is
+ return host;
+ }
+
+ // IPv4 or hostname: look for port separator
+ var colonIndex = host.IndexOf(':');
+ return colonIndex > 0 ? host.Substring(0, colonIndex) : host;
+ }
+ }
+
+ public class HttpsOptions
+ {
+ public bool Enabled { get; set; } = false;
+ public bool RedirectHttpToHttps { get; set; } = false;
+ public int? Port { get; set; } = null;
+ public bool EnableHsts { get; set; } = false;
+ public int HstsMaxAgeSeconds { get; set; } = 31536000;
+ public bool HstsIncludeSubDomains { get; set; } = false;
+ }
+
+ public static class HttpsExtensions
+ {
+ public static IAppBuilder UseHttpsMiddleware(this IAppBuilder builder, HttpsOptions options)
+ {
+ if (builder == null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+
+ if (options == null)
+ {
+ throw new ArgumentNullException(nameof(options));
+ }
+
+ if (options.Port.HasValue && (options.Port.Value < 1 || options.Port.Value > 65535))
+ {
+ throw new ArgumentOutOfRangeException(nameof(options), "Port must be between 1 and 65535.");
+ }
+
+ return builder.Use(options);
+ }
+ }
+}
diff --git a/src/ServicePulse.Host/Owin/OwinBootstrapper.cs b/src/ServicePulse.Host/Owin/OwinBootstrapper.cs
index 95ca100c85..40aa8065b0 100644
--- a/src/ServicePulse.Host/Owin/OwinBootstrapper.cs
+++ b/src/ServicePulse.Host/Owin/OwinBootstrapper.cs
@@ -1,11 +1,29 @@
-namespace ServicePulse.Host.Owin
+namespace ServicePulse.Host.Owin
{
using global::Owin;
public class OwinBootstrapper
{
+ public static ForwardedHeadersOptions ForwardedHeadersOptions { get; set; } = new ForwardedHeadersOptions();
+ public static HttpsOptions HttpsOptions { get; set; } = new HttpsOptions();
+ public static bool EnableDebugEndpoint { get; set; }
+
public void Configuration(IAppBuilder app)
{
+ // Forwarded headers must be first in the pipeline
+ app.UseForwardedHeaders(ForwardedHeadersOptions);
+
+ // HTTPS middleware (HSTS and HTTP-to-HTTPS redirect)
+ app.UseHttpsMiddleware(HttpsOptions);
+
+ // Debug endpoint for testing forwarded headers (only in debug builds)
+#if DEBUG
+ if (EnableDebugEndpoint)
+ {
+ app.UseDebugRequestInfo(ForwardedHeadersOptions);
+ }
+#endif
+
app.UseIndexUrlRewriter();
app.UseStaticFiles();
}
diff --git a/src/ServicePulse.Host/ServicePulse.Host.csproj b/src/ServicePulse.Host/ServicePulse.Host.csproj
index 2327fbfbf4..acbc561e9a 100644
--- a/src/ServicePulse.Host/ServicePulse.Host.csproj
+++ b/src/ServicePulse.Host/ServicePulse.Host.csproj
@@ -14,7 +14,7 @@
-
+
diff --git a/src/ServicePulse.Tests/.editorconfig b/src/ServicePulse.Tests/.editorconfig
new file mode 100644
index 0000000000..5527b268b4
--- /dev/null
+++ b/src/ServicePulse.Tests/.editorconfig
@@ -0,0 +1,16 @@
+[*.cs]
+
+# Justification: Test project
+dotnet_diagnostic.CA2007.severity = none
+
+# Justification: Tests don't support cancellation and don't need to forward IMessageHandlerContext.CancellationToken
+dotnet_diagnostic.NSB0002.severity = suggestion
+
+# Justification: Test middleware doesn't need cancellation token - it just sets RemoteIpAddress and calls next
+dotnet_diagnostic.PS0018.severity = suggestion
+
+# Justification: Test project - collection initialization style less critical
+dotnet_diagnostic.IDE0028.severity = suggestion
+
+# Justification: Test project - allow simplified collection expressions
+dotnet_diagnostic.IDE0305.severity = suggestion
diff --git a/src/ServicePulse.Tests/ForwardedHeaders/ForwardedHeadersTestBase.cs b/src/ServicePulse.Tests/ForwardedHeaders/ForwardedHeadersTestBase.cs
new file mode 100644
index 0000000000..ea6f692974
--- /dev/null
+++ b/src/ServicePulse.Tests/ForwardedHeaders/ForwardedHeadersTestBase.cs
@@ -0,0 +1,47 @@
+namespace ServicePulse.Tests.ForwardedHeaders;
+
+using System.Text.Json;
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Base class for ForwardedHeaders tests providing common setup, teardown, and helper methods.
+///
+public abstract class ForwardedHeadersTestBase
+{
+ protected ServicePulseWebApplicationFactory Factory = null!;
+ protected HttpClient Client = null!;
+
+ [TearDown]
+ public void TearDown()
+ {
+ Client.Dispose();
+ Factory.Dispose();
+ }
+
+ protected async Task SendRequestWithHeaders(
+ string? forwardedFor = null,
+ string? forwardedProto = null,
+ string? forwardedHost = null)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Get, "/debug/request-info");
+
+ if (forwardedFor != null)
+ {
+ request.Headers.Add("X-Forwarded-For", forwardedFor);
+ }
+ if (forwardedProto != null)
+ {
+ request.Headers.Add("X-Forwarded-Proto", forwardedProto);
+ }
+ if (forwardedHost != null)
+ {
+ request.Headers.Add("X-Forwarded-Host", forwardedHost);
+ }
+
+ var response = await Client.SendAsync(request);
+ response.EnsureSuccessStatusCode();
+
+ var content = await response.Content.ReadAsStringAsync();
+ return JsonSerializer.Deserialize(content)!;
+ }
+}
diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs
new file mode 100644
index 0000000000..e12089d732
--- /dev/null
+++ b/src/ServicePulse.Tests/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs
@@ -0,0 +1,45 @@
+namespace ServicePulse.Tests.ForwardedHeaders;
+
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Tests behavior when both known proxies and known networks are configured.
+///
+[TestFixture]
+public class When_combined_proxies_and_networks_are_configured : ForwardedHeadersTestBase
+{
+ [SetUp]
+ public void SetUp()
+ {
+ // Configure both a specific proxy and a network
+ Factory = TestConfiguration.CreateWithKnownProxiesAndNetworks(
+ ["10.0.0.1"],
+ ["127.0.0.0/8"]);
+ Client = Factory.CreateClient();
+ }
+
+ [Test]
+ public async Task Headers_should_be_processed_when_matching_network()
+ {
+ // Request comes from 127.0.0.1 which is in the known network
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ // Headers should be processed since request comes from known network
+ Assert.That(result.Processed.Scheme, Is.EqualTo("https"));
+ Assert.That(result.Processed.Host, Is.EqualTo("example.com"));
+ }
+
+ [Test]
+ public async Task Configuration_should_show_both_proxies_and_networks()
+ {
+ var result = await SendRequestWithHeaders();
+
+ Assert.That(result.Configuration.Enabled, Is.True);
+ Assert.That(result.Configuration.TrustAllProxies, Is.False);
+ Assert.That(result.Configuration.KnownProxies, Does.Contain("10.0.0.1"));
+ Assert.That(result.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8"));
+ }
+}
diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_forward_limit_is_applied.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_forward_limit_is_applied.cs
new file mode 100644
index 0000000000..17727ceb05
--- /dev/null
+++ b/src/ServicePulse.Tests/ForwardedHeaders/When_forward_limit_is_applied.cs
@@ -0,0 +1,82 @@
+namespace ServicePulse.Tests.ForwardedHeaders;
+
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Tests ForwardLimit behavior which controls how many proxy hops are processed.
+/// When TrustAllProxies=true, ForwardLimit is ignored (all entries processed).
+/// When TrustAllProxies=false, ForwardLimit defaults to 1 (only rightmost entry processed).
+///
+[TestFixture]
+public class When_forward_limit_is_applied : ForwardedHeadersTestBase
+{
+ [Test]
+ public async Task Trust_all_proxies_should_process_entire_chain()
+ {
+ // TrustAllProxies=true means ForwardLimit=null (no limit)
+ Factory = TestConfiguration.CreateWithForwardedHeadersDefaults();
+ Client = Factory.CreateClient();
+
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1",
+ forwardedProto: "https");
+
+ // Should process all entries and return the leftmost (original client)
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50"));
+
+ // All entries should be consumed
+ Assert.That(result.RawHeaders.XForwardedFor, Is.Empty);
+ }
+
+ [Test]
+ public async Task Known_proxies_should_apply_forward_limit_of_one()
+ {
+ // TrustAllProxies=false means ForwardLimit=1
+ Factory = TestConfiguration.CreateWithKnownProxies("127.0.0.1");
+ Client = Factory.CreateClient();
+
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1",
+ forwardedProto: "https");
+
+ // With ForwardLimit=1, should only process the rightmost entry
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("192.168.1.1"));
+
+ // Remaining entries should stay in the header
+ Assert.That(result.RawHeaders.XForwardedFor, Is.EqualTo("203.0.113.50,10.0.0.1"));
+ }
+
+ [Test]
+ public async Task Single_entry_should_be_processed_regardless_of_limit()
+ {
+ Factory = TestConfiguration.CreateWithKnownProxies("127.0.0.1");
+ Client = Factory.CreateClient();
+
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https");
+
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50"));
+ Assert.That(result.RawHeaders.XForwardedFor, Is.Empty);
+ }
+
+ [Test]
+ public async Task Proto_and_host_should_use_rightmost_value()
+ {
+ // Proto and host always use the rightmost (most recent proxy) value
+ Factory = TestConfiguration.CreateWithKnownProxies("127.0.0.1");
+ Client = Factory.CreateClient();
+
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50, 10.0.0.1",
+ forwardedProto: "https, http",
+ forwardedHost: "public.example.com, internal.example.com");
+
+ // Proto and host use rightmost value (most recent proxy)
+ Assert.That(result.Processed.Scheme, Is.EqualTo("http"));
+ Assert.That(result.Processed.Host, Is.EqualTo("internal.example.com"));
+
+ // ForwardedFor also uses rightmost with ForwardLimit=1
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("10.0.0.1"));
+ }
+}
diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_forwarded_for_contains_ports.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_forwarded_for_contains_ports.cs
new file mode 100644
index 0000000000..d16924708e
--- /dev/null
+++ b/src/ServicePulse.Tests/ForwardedHeaders/When_forwarded_for_contains_ports.cs
@@ -0,0 +1,60 @@
+namespace ServicePulse.Tests.ForwardedHeaders;
+
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Tests behavior when X-Forwarded-For header contains IP addresses with port numbers.
+///
+[TestFixture]
+public class When_forwarded_for_contains_ports : ForwardedHeadersTestBase
+{
+ [SetUp]
+ public void SetUp()
+ {
+ Factory = TestConfiguration.CreateWithForwardedHeadersDefaults();
+ Client = Factory.CreateClient();
+ }
+
+ [Test]
+ public async Task Should_strip_port_from_ipv4_address()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50:8080",
+ forwardedProto: "https");
+
+ // Port should be stripped, leaving just the IP
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50"));
+ }
+
+ [Test]
+ public async Task Should_strip_port_from_ipv6_address()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "[2001:db8::1]:443",
+ forwardedProto: "https");
+
+ // Port and brackets should be stripped
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("2001:db8::1"));
+ }
+
+ [Test]
+ public async Task Should_handle_mixed_ports_in_chain()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50:12345, 10.0.0.1:8080, 192.168.1.1",
+ forwardedProto: "https");
+
+ // Should use leftmost IP with port stripped
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50"));
+ }
+
+ [Test]
+ public async Task Should_handle_address_without_port()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https");
+
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50"));
+ }
+}
diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_forwarded_headers_are_disabled.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_forwarded_headers_are_disabled.cs
new file mode 100644
index 0000000000..640e438061
--- /dev/null
+++ b/src/ServicePulse.Tests/ForwardedHeaders/When_forwarded_headers_are_disabled.cs
@@ -0,0 +1,44 @@
+namespace ServicePulse.Tests.ForwardedHeaders;
+
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Tests behavior when forwarded headers processing is disabled.
+///
+[TestFixture]
+public class When_forwarded_headers_are_disabled : ForwardedHeadersTestBase
+{
+ [SetUp]
+ public void SetUp()
+ {
+ Factory = TestConfiguration.CreateWithForwardedHeadersDisabled();
+ Client = Factory.CreateClient();
+ }
+
+ [Test]
+ public async Task Headers_should_not_be_processed()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ // Headers should NOT be processed when disabled
+ Assert.That(result.Processed.Scheme, Is.EqualTo("http"));
+ Assert.That(result.Processed.Host, Is.Not.EqualTo("example.com"));
+ }
+
+ [Test]
+ public async Task Headers_should_remain_unmodified()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ // Headers should remain in request when disabled
+ Assert.That(result.RawHeaders.XForwardedFor, Is.EqualTo("203.0.113.50"));
+ Assert.That(result.RawHeaders.XForwardedProto, Is.EqualTo("https"));
+ Assert.That(result.RawHeaders.XForwardedHost, Is.EqualTo("example.com"));
+ }
+}
diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_forwarded_headers_are_sent.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_forwarded_headers_are_sent.cs
new file mode 100644
index 0000000000..70b23b00a5
--- /dev/null
+++ b/src/ServicePulse.Tests/ForwardedHeaders/When_forwarded_headers_are_sent.cs
@@ -0,0 +1,44 @@
+namespace ServicePulse.Tests.ForwardedHeaders;
+
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Tests default behavior when forwarded headers are sent and all proxies are trusted.
+///
+[TestFixture]
+public class When_forwarded_headers_are_sent : ForwardedHeadersTestBase
+{
+ [SetUp]
+ public void SetUp()
+ {
+ Factory = TestConfiguration.CreateWithForwardedHeadersDefaults();
+ Client = Factory.CreateClient();
+ }
+
+ [Test]
+ public async Task Headers_should_be_applied_when_trust_all_proxies()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ Assert.That(result.Processed.Scheme, Is.EqualTo("https"));
+ Assert.That(result.Processed.Host, Is.EqualTo("example.com"));
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50"));
+ }
+
+ [Test]
+ public async Task Headers_should_be_consumed_after_processing()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ // Headers should be consumed (removed) after processing
+ Assert.That(result.RawHeaders.XForwardedFor, Is.Empty);
+ Assert.That(result.RawHeaders.XForwardedProto, Is.Empty);
+ Assert.That(result.RawHeaders.XForwardedHost, Is.Empty);
+ }
+}
diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_headers_are_malformed.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_headers_are_malformed.cs
new file mode 100644
index 0000000000..93b6e583eb
--- /dev/null
+++ b/src/ServicePulse.Tests/ForwardedHeaders/When_headers_are_malformed.cs
@@ -0,0 +1,97 @@
+namespace ServicePulse.Tests.ForwardedHeaders;
+
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Tests behavior when forwarded headers contain malformed or edge-case values.
+///
+[TestFixture]
+public class When_headers_are_malformed : ForwardedHeadersTestBase
+{
+ [SetUp]
+ public void SetUp()
+ {
+ Factory = TestConfiguration.CreateWithForwardedHeadersDefaults();
+ Client = Factory.CreateClient();
+ }
+
+ [Test]
+ public async Task Should_handle_empty_forwarded_for_entries()
+ {
+ // Header with empty entry in the middle
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50, , 192.168.1.1",
+ forwardedProto: "https");
+
+ // Should still process and use a valid IP
+ Assert.That(result.Processed.Scheme, Is.EqualTo("https"));
+ }
+
+ [Test]
+ public async Task Should_handle_whitespace_only_entries()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50, ,192.168.1.1",
+ forwardedProto: "https");
+
+ Assert.That(result.Processed.Scheme, Is.EqualTo("https"));
+ }
+
+ [Test]
+ public async Task Should_handle_single_value_with_trailing_comma()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50,",
+ forwardedProto: "https");
+
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50"));
+ }
+
+ [Test]
+ public async Task Should_fallback_when_ip_has_whitespace()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: " 203.0.113.50 , 10.0.0.1 ",
+ forwardedProto: "https");
+
+ // ASP.NET Core doesn't trim whitespace from IP addresses - falls back to original
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("127.0.0.1"));
+ }
+
+ [Test]
+ public async Task Should_preserve_original_scheme_for_invalid_proto()
+ {
+ // Invalid protocol value - should either be ignored or handled gracefully
+ var result = await SendRequestWithHeaders(forwardedProto: "ftp");
+
+ // Behavior depends on implementation - at minimum shouldn't crash
+ Assert.That(result.Processed.Scheme, Is.Not.Null);
+ }
+
+ [Test]
+ public async Task Should_handle_empty_proto_value()
+ {
+ var result = await SendRequestWithHeaders(forwardedProto: "");
+
+ // Empty proto should fall back to original
+ Assert.That(result.Processed.Scheme, Is.EqualTo("http"));
+ }
+
+ [Test]
+ public async Task Should_fallback_when_proto_has_whitespace()
+ {
+ var result = await SendRequestWithHeaders(forwardedProto: " https ");
+
+ // ASP.NET Core doesn't trim whitespace from proto values - falls back to original
+ Assert.That(result.Processed.Scheme, Is.EqualTo("http"));
+ }
+
+ [Test]
+ public async Task Should_fallback_to_original_host_when_whitespace_present()
+ {
+ var result = await SendRequestWithHeaders(forwardedHost: " example.com ");
+
+ // ASP.NET Core doesn't trim whitespace from host values - falls back to original
+ Assert.That(result.Processed.Host, Is.EqualTo("localhost"));
+ }
+}
diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_ipv4_mapped_ipv6_addresses_are_used.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_ipv4_mapped_ipv6_addresses_are_used.cs
new file mode 100644
index 0000000000..aeb6fb4548
--- /dev/null
+++ b/src/ServicePulse.Tests/ForwardedHeaders/When_ipv4_mapped_ipv6_addresses_are_used.cs
@@ -0,0 +1,79 @@
+namespace ServicePulse.Tests.ForwardedHeaders;
+
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Tests behavior when IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are used.
+/// These should be normalized and match their IPv4 equivalents in KnownProxies.
+///
+[TestFixture]
+public class When_ipv4_mapped_ipv6_addresses_are_used : ForwardedHeadersTestBase
+{
+ [Test]
+ public async Task Ipv4_mapped_ipv6_proxy_should_match_ipv4_known_proxy()
+ {
+ // Configure 127.0.0.1 as known proxy
+ // Request comes from ::ffff:127.0.0.1 (IPv4-mapped IPv6)
+ Factory = TestConfiguration.CreateWithKnownProxies("127.0.0.1")
+ .WithRemoteIpAddress("::ffff:127.0.0.1");
+ Client = Factory.CreateClient();
+
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ // Headers should be processed since ::ffff:127.0.0.1 matches 127.0.0.1
+ Assert.That(result.Processed.Scheme, Is.EqualTo("https"));
+ Assert.That(result.Processed.Host, Is.EqualTo("example.com"));
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50"));
+ }
+
+ [Test]
+ public async Task Ipv4_proxy_does_not_match_ipv4_mapped_ipv6_known_proxy()
+ {
+ // Configure ::ffff:127.0.0.1 as known proxy
+ // Request comes from 127.0.0.1 (plain IPv4)
+ Factory = TestConfiguration.CreateWithKnownProxies("::ffff:127.0.0.1");
+ Client = Factory.CreateClient();
+
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ // ASP.NET Core doesn't normalize IPv4-mapped IPv6 - no match, headers not processed
+ Assert.That(result.Processed.Scheme, Is.EqualTo("http"));
+ Assert.That(result.Processed.Host, Is.EqualTo("localhost"));
+ }
+
+ [Test]
+ public async Task Ipv4_mapped_address_in_forwarded_for_should_be_processed()
+ {
+ Factory = TestConfiguration.CreateWithForwardedHeadersDefaults();
+ Client = Factory.CreateClient();
+
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "::ffff:203.0.113.50",
+ forwardedProto: "https");
+
+ // The IPv4-mapped address should be processed
+ // Note: The stored value format depends on implementation
+ Assert.That(result.Processed.RemoteIpAddress, Is.Not.Null.And.Not.Empty);
+ Assert.That(result.Processed.Scheme, Is.EqualTo("https"));
+ }
+
+ [Test]
+ public async Task Mixed_ipv4_and_mapped_addresses_in_chain()
+ {
+ Factory = TestConfiguration.CreateWithForwardedHeadersDefaults();
+ Client = Factory.CreateClient();
+
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "::ffff:203.0.113.50, 10.0.0.1, ::ffff:192.168.1.1",
+ forwardedProto: "https");
+
+ // Should process the chain and return leftmost
+ Assert.That(result.Processed.Scheme, Is.EqualTo("https"));
+ }
+}
diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_ipv6_addresses_are_used.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_ipv6_addresses_are_used.cs
new file mode 100644
index 0000000000..1e21ccc37f
--- /dev/null
+++ b/src/ServicePulse.Tests/ForwardedHeaders/When_ipv6_addresses_are_used.cs
@@ -0,0 +1,82 @@
+namespace ServicePulse.Tests.ForwardedHeaders;
+
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Tests behavior when IPv6 addresses are used in X-Forwarded-For headers.
+///
+[TestFixture]
+public class When_ipv6_addresses_are_used : ForwardedHeadersTestBase
+{
+ [SetUp]
+ public void SetUp()
+ {
+ Factory = TestConfiguration.CreateWithForwardedHeadersDefaults();
+ Client = Factory.CreateClient();
+ }
+
+ [Test]
+ public async Task Should_process_unbracketed_ipv6_address()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "2001:db8::1",
+ forwardedProto: "https");
+
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("2001:db8::1"));
+ Assert.That(result.Processed.Scheme, Is.EqualTo("https"));
+ }
+
+ [Test]
+ public async Task Should_process_bracketed_ipv6_address()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "[2001:db8::1]",
+ forwardedProto: "https");
+
+ // Brackets should be stripped
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("2001:db8::1"));
+ }
+
+ [Test]
+ public async Task Should_process_bracketed_ipv6_address_with_port()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "[2001:db8::1]:8080",
+ forwardedProto: "https");
+
+ // Should extract just the IPv6 address without brackets or port
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("2001:db8::1"));
+ }
+
+ [Test]
+ public async Task Should_process_loopback_ipv6_address()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "::1",
+ forwardedProto: "https");
+
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("::1"));
+ }
+
+ [Test]
+ public async Task Should_process_ipv6_chain()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "2001:db8::100, 2001:db8::1, ::1",
+ forwardedProto: "https");
+
+ // Should use the leftmost (original client) IP
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("2001:db8::100"));
+ }
+
+ [Test]
+ public async Task Should_process_mixed_ipv4_ipv6_chain()
+ {
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50, 2001:db8::1, 192.168.1.1",
+ forwardedProto: "https");
+
+ // Should use the leftmost (original client) IP
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50"));
+ }
+}
diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_only_proto_header_is_sent.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_only_proto_header_is_sent.cs
new file mode 100644
index 0000000000..9dd1fc4c4a
--- /dev/null
+++ b/src/ServicePulse.Tests/ForwardedHeaders/When_only_proto_header_is_sent.cs
@@ -0,0 +1,38 @@
+namespace ServicePulse.Tests.ForwardedHeaders;
+
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Tests behavior when only the X-Forwarded-Proto header is sent.
+///
+[TestFixture]
+public class When_only_proto_header_is_sent : ForwardedHeadersTestBase
+{
+ [SetUp]
+ public void SetUp()
+ {
+ Factory = TestConfiguration.CreateWithForwardedHeadersDefaults();
+ Client = Factory.CreateClient();
+ }
+
+ [Test]
+ public async Task Scheme_should_be_updated()
+ {
+ // Only X-Forwarded-Proto, no X-Forwarded-For or X-Forwarded-Host
+ var result = await SendRequestWithHeaders(forwardedProto: "https");
+
+ // Scheme should be updated
+ Assert.That(result.Processed.Scheme, Is.EqualTo("https"));
+ }
+
+ [Test]
+ public async Task Host_and_ip_should_use_original_values()
+ {
+ // Only X-Forwarded-Proto, no X-Forwarded-For or X-Forwarded-Host
+ var result = await SendRequestWithHeaders(forwardedProto: "https");
+
+ // Host and IP should remain original
+ Assert.That(result.Processed.Host, Does.Contain("localhost"));
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("127.0.0.1"));
+ }
+}
diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs
new file mode 100644
index 0000000000..46f49edd60
--- /dev/null
+++ b/src/ServicePulse.Tests/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs
@@ -0,0 +1,51 @@
+namespace ServicePulse.Tests.ForwardedHeaders;
+
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Tests behavior when proxy chain headers contain multiple values.
+/// ASP.NET Core's ForwardedHeaders middleware uses the FIRST (leftmost) value
+/// when processing comma-separated header values.
+///
+[TestFixture]
+public class When_proxy_chain_headers_are_sent : ForwardedHeadersTestBase
+{
+ [SetUp]
+ public void SetUp()
+ {
+ Factory = TestConfiguration.CreateWithForwardedHeadersDefaults();
+ Client = Factory.CreateClient();
+ }
+
+ [Test]
+ public async Task Should_use_leftmost_ip_from_chain()
+ {
+ // Leftmost is original client, rightmost is the most recent proxy
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1",
+ forwardedProto: "https");
+
+ // ASP.NET Core processes the chain from right to left and ends with the leftmost client IP
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50"));
+ }
+
+ [Test]
+ public async Task Should_use_first_proto_from_chain()
+ {
+ // ASP.NET Core uses the first value in comma-separated list
+ var result = await SendRequestWithHeaders(forwardedProto: "https, http");
+
+ // Should use the first (leftmost) protocol value
+ Assert.That(result.Processed.Scheme, Is.EqualTo("https"));
+ }
+
+ [Test]
+ public async Task Should_use_first_host_from_chain()
+ {
+ // ASP.NET Core uses the first value in comma-separated list
+ var result = await SendRequestWithHeaders(forwardedHost: "public.example.com, proxy.internal, original.com");
+
+ // Should use the first (leftmost) host value
+ Assert.That(result.Processed.Host, Is.EqualTo("public.example.com"));
+ }
+}
diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_request_has_no_forwarded_headers.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_request_has_no_forwarded_headers.cs
new file mode 100644
index 0000000000..e3d18a76ce
--- /dev/null
+++ b/src/ServicePulse.Tests/ForwardedHeaders/When_request_has_no_forwarded_headers.cs
@@ -0,0 +1,29 @@
+namespace ServicePulse.Tests.ForwardedHeaders;
+
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Tests behavior when no forwarded headers are present in the request.
+///
+[TestFixture]
+public class When_request_has_no_forwarded_headers : ForwardedHeadersTestBase
+{
+ [SetUp]
+ public void SetUp()
+ {
+ Factory = TestConfiguration.CreateWithForwardedHeadersDefaults();
+ Client = Factory.CreateClient();
+ }
+
+ [Test]
+ public async Task Original_values_should_be_preserved()
+ {
+ // No forwarded headers
+ var result = await SendRequestWithHeaders();
+
+ // Original values should be used
+ Assert.That(result.Processed.Scheme, Is.EqualTo("http"));
+ Assert.That(result.Processed.Host, Does.Contain("localhost"));
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("127.0.0.1"));
+ }
+}
diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_trusted_source_sends_headers.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_trusted_source_sends_headers.cs
new file mode 100644
index 0000000000..43a7be238a
--- /dev/null
+++ b/src/ServicePulse.Tests/ForwardedHeaders/When_trusted_source_sends_headers.cs
@@ -0,0 +1,57 @@
+namespace ServicePulse.Tests.ForwardedHeaders;
+
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Tests behavior when headers are sent from a trusted source (known proxy or within a known network).
+///
+[TestFixture]
+public class When_trusted_source_sends_headers : ForwardedHeadersTestBase
+{
+ [TestCase("127.0.0.1", null, TestName = "Known_proxy")]
+ [TestCase(null, "127.0.0.0/8", TestName = "Known_network")]
+ public async Task Headers_should_be_processed(string? knownProxy, string? knownNetwork)
+ {
+ // Configure known proxy or network that matches the simulated remote IP (127.0.0.1)
+ Factory = knownProxy != null
+ ? TestConfiguration.CreateWithKnownProxies(knownProxy)
+ : TestConfiguration.CreateWithKnownNetworks(knownNetwork!);
+ Client = Factory.CreateClient();
+
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ // Headers should be processed since request comes from trusted source
+ Assert.That(result.Processed.Scheme, Is.EqualTo("https"));
+ Assert.That(result.Processed.Host, Is.EqualTo("example.com"));
+ Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50"));
+ }
+
+ [Test]
+ public async Task Configuration_should_show_known_proxies()
+ {
+ Factory = TestConfiguration.CreateWithKnownProxies("127.0.0.1");
+ Client = Factory.CreateClient();
+
+ var result = await SendRequestWithHeaders();
+
+ Assert.That(result.Configuration.Enabled, Is.True);
+ Assert.That(result.Configuration.TrustAllProxies, Is.False);
+ Assert.That(result.Configuration.KnownProxies, Does.Contain("127.0.0.1"));
+ }
+
+ [Test]
+ public async Task Configuration_should_show_known_networks()
+ {
+ Factory = TestConfiguration.CreateWithKnownNetworks("127.0.0.0/8");
+ Client = Factory.CreateClient();
+
+ var result = await SendRequestWithHeaders();
+
+ Assert.That(result.Configuration.Enabled, Is.True);
+ Assert.That(result.Configuration.TrustAllProxies, Is.False);
+ Assert.That(result.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8"));
+ }
+}
diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_untrusted_source_sends_headers.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_untrusted_source_sends_headers.cs
new file mode 100644
index 0000000000..4a305ce038
--- /dev/null
+++ b/src/ServicePulse.Tests/ForwardedHeaders/When_untrusted_source_sends_headers.cs
@@ -0,0 +1,57 @@
+namespace ServicePulse.Tests.ForwardedHeaders;
+
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Tests behavior when headers are sent from an untrusted source (neither a known proxy nor within a known network).
+///
+[TestFixture]
+public class When_untrusted_source_sends_headers : ForwardedHeadersTestBase
+{
+ [SetUp]
+ public void SetUp()
+ {
+ // Default setup - will be overridden by test cases
+ }
+
+ [TestCase("10.0.0.1", null, TestName = "Unknown_proxy")]
+ [TestCase(null, "10.0.0.0/8", TestName = "Unknown_network")]
+ public async Task Headers_should_not_be_processed(string? knownProxy, string? knownNetwork)
+ {
+ // Configure known proxy or network that doesn't match the simulated remote IP
+ Factory = knownProxy != null
+ ? TestConfiguration.CreateWithKnownProxies(knownProxy).WithRemoteIpAddress("192.168.1.100")
+ : TestConfiguration.CreateWithKnownNetworks(knownNetwork!).WithRemoteIpAddress("192.168.1.100");
+ Client = Factory.CreateClient();
+
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ // Headers should NOT be processed since request doesn't come from trusted source
+ Assert.That(result.Processed.Scheme, Is.EqualTo("http"));
+ Assert.That(result.Processed.Host, Is.Not.EqualTo("example.com"));
+ Assert.That(result.Processed.RemoteIpAddress, Is.Not.EqualTo("203.0.113.50"));
+ }
+
+ [TestCase("10.0.0.1", null, TestName = "Unknown_proxy")]
+ [TestCase(null, "10.0.0.0/8", TestName = "Unknown_network")]
+ public async Task Headers_should_remain_in_request(string? knownProxy, string? knownNetwork)
+ {
+ Factory = knownProxy != null
+ ? TestConfiguration.CreateWithKnownProxies(knownProxy).WithRemoteIpAddress("192.168.1.100")
+ : TestConfiguration.CreateWithKnownNetworks(knownNetwork!).WithRemoteIpAddress("192.168.1.100");
+ Client = Factory.CreateClient();
+
+ var result = await SendRequestWithHeaders(
+ forwardedFor: "203.0.113.50",
+ forwardedProto: "https",
+ forwardedHost: "example.com");
+
+ // Headers should remain unprocessed when from untrusted source
+ Assert.That(result.RawHeaders.XForwardedFor, Is.EqualTo("203.0.113.50"));
+ Assert.That(result.RawHeaders.XForwardedProto, Is.EqualTo("https"));
+ Assert.That(result.RawHeaders.XForwardedHost, Is.EqualTo("example.com"));
+ }
+}
diff --git a/src/ServicePulse.Tests/Https/HttpsTestBase.cs b/src/ServicePulse.Tests/Https/HttpsTestBase.cs
new file mode 100644
index 0000000000..8c6a53f254
--- /dev/null
+++ b/src/ServicePulse.Tests/Https/HttpsTestBase.cs
@@ -0,0 +1,67 @@
+namespace ServicePulse.Tests.Https;
+
+using Microsoft.AspNetCore.Mvc.Testing;
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Base class for HTTPS tests providing common setup, teardown, and helper methods.
+///
+public abstract class HttpsTestBase
+{
+ protected ServicePulseWebApplicationFactory Factory = null!;
+ protected HttpClient Client = null!;
+
+ [TearDown]
+ public void TearDown()
+ {
+ Client?.Dispose();
+ Factory?.Dispose();
+ }
+
+ protected HttpClient CreateHttpClient(bool allowAutoRedirect = true)
+ {
+ return Factory.CreateClient(new WebApplicationFactoryClientOptions
+ {
+ AllowAutoRedirect = allowAutoRedirect,
+ BaseAddress = new Uri("http://localhost")
+ });
+ }
+
+ protected HttpClient CreateHttpsClient(bool allowAutoRedirect = true)
+ {
+ return Factory.CreateClient(new WebApplicationFactoryClientOptions
+ {
+ AllowAutoRedirect = allowAutoRedirect,
+ BaseAddress = new Uri("https://localhost")
+ });
+ }
+
+ protected async Task SendHttpRequest(
+ string path,
+ HttpMethod? method = null,
+ string? forwardedProto = null,
+ string? forwardedHost = null)
+ {
+ var request = new HttpRequestMessage(method ?? HttpMethod.Get, path);
+
+ if (forwardedProto != null)
+ {
+ request.Headers.Add("X-Forwarded-Proto", forwardedProto);
+ }
+ if (forwardedHost != null)
+ {
+ request.Headers.Add("X-Forwarded-Host", forwardedHost);
+ }
+
+ return await Client.SendAsync(request);
+ }
+
+ protected static string? GetHstsHeaderValue(HttpResponseMessage response)
+ {
+ if (response.Headers.Contains("Strict-Transport-Security"))
+ {
+ return string.Join("", response.Headers.GetValues("Strict-Transport-Security"));
+ }
+ return null;
+ }
+}
diff --git a/src/ServicePulse.Tests/Https/When_hsts_is_enabled.cs b/src/ServicePulse.Tests/Https/When_hsts_is_enabled.cs
new file mode 100644
index 0000000000..77994281fd
--- /dev/null
+++ b/src/ServicePulse.Tests/Https/When_hsts_is_enabled.cs
@@ -0,0 +1,68 @@
+namespace ServicePulse.Tests.Https;
+
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Tests HSTS (HTTP Strict Transport Security) header behavior.
+/// HSTS requires Production environment (disabled in Development by ASP.NET Core).
+///
+[TestFixture]
+public class When_hsts_is_enabled : HttpsTestBase
+{
+ [Test]
+ public async Task Should_add_hsts_header_for_https_request()
+ {
+ Factory = TestConfiguration.CreateWithHstsEnabled();
+ Client = CreateHttpsClient();
+
+ var response = await Client.GetAsync("/js/app.constants.js");
+
+ var hstsValue = GetHstsHeaderValue(response);
+ Assert.That(hstsValue, Is.Not.Null);
+ Assert.That(hstsValue, Does.Contain("max-age=31536000"));
+ }
+
+ [Test]
+ public async Task Should_not_add_hsts_header_for_http_request()
+ {
+ Factory = TestConfiguration.CreateWithHstsEnabled();
+ Client = CreateHttpClient();
+
+ var response = await Client.GetAsync("/js/app.constants.js");
+
+ Assert.That(GetHstsHeaderValue(response), Is.Null);
+ }
+
+ [Test]
+ public async Task Should_use_custom_max_age()
+ {
+ Factory = TestConfiguration.CreateWithHstsEnabled(maxAgeSeconds: 86400);
+ Client = CreateHttpsClient();
+
+ var response = await Client.GetAsync("/js/app.constants.js");
+
+ Assert.That(GetHstsHeaderValue(response), Is.EqualTo("max-age=86400"));
+ }
+
+ [Test]
+ public async Task Should_include_subdomains_when_configured()
+ {
+ Factory = TestConfiguration.CreateWithHstsEnabled(includeSubDomains: true);
+ Client = CreateHttpsClient();
+
+ var response = await Client.GetAsync("/js/app.constants.js");
+
+ Assert.That(GetHstsHeaderValue(response), Does.Contain("includeSubDomains"));
+ }
+
+ [Test]
+ public async Task Should_not_include_subdomains_by_default()
+ {
+ Factory = TestConfiguration.CreateWithHstsEnabled(includeSubDomains: false);
+ Client = CreateHttpsClient();
+
+ var response = await Client.GetAsync("/js/app.constants.js");
+
+ Assert.That(GetHstsHeaderValue(response), Does.Not.Contain("includeSubDomains"));
+ }
+}
diff --git a/src/ServicePulse.Tests/Https/When_https_is_disabled.cs b/src/ServicePulse.Tests/Https/When_https_is_disabled.cs
new file mode 100644
index 0000000000..880e9f624a
--- /dev/null
+++ b/src/ServicePulse.Tests/Https/When_https_is_disabled.cs
@@ -0,0 +1,45 @@
+namespace ServicePulse.Tests.Https;
+
+using System.Net;
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Tests behavior when all HTTPS features are disabled.
+///
+[TestFixture]
+public class When_https_is_disabled : HttpsTestBase
+{
+ [SetUp]
+ public void SetUp()
+ {
+ Factory = TestConfiguration.CreateWithHttpsDisabled();
+ Client = CreateHttpClient();
+ }
+
+ [Test]
+ public async Task Should_not_add_hsts_header()
+ {
+ var response = await Client.GetAsync("/js/app.constants.js");
+
+ Assert.That(response.Headers.Contains("Strict-Transport-Security"), Is.False);
+ }
+
+ [Test]
+ public async Task Should_not_redirect_http_request()
+ {
+ var response = await Client.GetAsync("/js/app.constants.js");
+
+ // Should not redirect
+ Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.TemporaryRedirect));
+ Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.PermanentRedirect));
+ Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.MovedPermanently));
+ }
+
+ [Test]
+ public async Task Should_serve_content_normally()
+ {
+ var response = await Client.GetAsync("/js/app.constants.js");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ }
+}
diff --git a/src/ServicePulse.Tests/Https/When_https_redirect_is_enabled.cs b/src/ServicePulse.Tests/Https/When_https_redirect_is_enabled.cs
new file mode 100644
index 0000000000..786f16908f
--- /dev/null
+++ b/src/ServicePulse.Tests/Https/When_https_redirect_is_enabled.cs
@@ -0,0 +1,89 @@
+namespace ServicePulse.Tests.Https;
+
+using System.Net;
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Tests HTTP to HTTPS redirect behavior.
+///
+[TestFixture]
+public class When_https_redirect_is_enabled : HttpsTestBase
+{
+ [SetUp]
+ public void SetUp()
+ {
+ Factory = TestConfiguration.CreateWithHttpsRedirectEnabled();
+ Client = CreateHttpClient(allowAutoRedirect: false);
+ }
+
+ [Test]
+ public async Task Should_redirect_http_to_https()
+ {
+ var response = await Client.GetAsync("/some/path");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ Assert.That(response.Headers.Location?.ToString(), Does.StartWith("https://"));
+ }
+
+ [Test]
+ public async Task Should_not_redirect_https_request()
+ {
+ using var httpsClient = CreateHttpsClient(allowAutoRedirect: false);
+
+ var response = await httpsClient.GetAsync("/js/app.constants.js");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ }
+
+ [Test]
+ public async Task Should_include_custom_port_in_redirect()
+ {
+ Factory.Dispose();
+ Client.Dispose();
+
+ Factory = TestConfiguration.CreateWithHttpsRedirectEnabled(httpsPort: 8443);
+ Client = CreateHttpClient(allowAutoRedirect: false);
+
+ var response = await Client.GetAsync("/test");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ Assert.That(response.Headers.Location?.ToString(), Does.Contain(":8443"));
+ }
+
+ [Test]
+ public async Task Should_omit_port_443_in_redirect()
+ {
+ // Port 443 is the default HTTPS port and should be omitted
+ var response = await Client.GetAsync("/test");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ Assert.That(response.Headers.Location?.ToString(), Does.Not.Contain(":443"));
+ }
+
+ [Test]
+ public async Task Should_preserve_path_in_redirect()
+ {
+ var response = await Client.GetAsync("/some/path");
+
+ Assert.That(response.Headers.Location?.ToString(), Does.Contain("/some/path"));
+ }
+
+ [Test]
+ public async Task Should_preserve_query_string()
+ {
+ var response = await Client.GetAsync("/test?foo=bar&baz=qux");
+
+ Assert.That(response.Headers.Location?.ToString(), Does.Contain("foo=bar"));
+ Assert.That(response.Headers.Location?.ToString(), Does.Contain("baz=qux"));
+ }
+
+ [Test]
+ public async Task Should_use_307_to_preserve_http_method()
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, "/api/data");
+ var response = await Client.SendAsync(request);
+
+ // 307 preserves the HTTP method (important for POST, PUT, etc.)
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ }
+}
diff --git a/src/ServicePulse.Tests/Https/When_https_with_forwarded_headers.cs b/src/ServicePulse.Tests/Https/When_https_with_forwarded_headers.cs
new file mode 100644
index 0000000000..bd8463e8cb
--- /dev/null
+++ b/src/ServicePulse.Tests/Https/When_https_with_forwarded_headers.cs
@@ -0,0 +1,99 @@
+namespace ServicePulse.Tests.Https;
+
+using System.Net;
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Tests HTTPS behavior when behind a reverse proxy using forwarded headers.
+/// This simulates scenarios where TLS termination happens at the proxy.
+///
+/// Note: ASP.NET Core's UseHsts middleware adds HSTS header based on the processed scheme
+/// after ForwardedHeaders middleware has run.
+///
+[TestFixture]
+public class When_https_with_forwarded_headers : HttpsTestBase
+{
+ [SetUp]
+ public void SetUp()
+ {
+ Factory = TestConfiguration.CreateWithForwardedHeadersAndHttps();
+ Client = CreateHttpClient(allowAutoRedirect: false);
+ }
+
+ [Test]
+ public async Task Should_not_redirect_when_forwarded_proto_is_https()
+ {
+ var response = await SendHttpRequest("/js/app.constants.js", forwardedProto: "https");
+
+ // Should not redirect because effective scheme is already HTTPS (from forwarded header)
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ }
+
+ [Test]
+ public async Task Should_redirect_when_no_forwarded_proto()
+ {
+ var response = await SendHttpRequest("/test");
+
+ // Should redirect because effective scheme is HTTP
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ }
+
+ [Test]
+ public async Task Should_use_forwarded_host_in_redirect_when_http()
+ {
+ var response = await SendHttpRequest("/test", forwardedHost: "example.com");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ Assert.That(response.Headers.Location?.ToString(), Does.Contain("example.com"));
+ }
+
+ [Test]
+ public async Task Should_add_hsts_when_request_is_https()
+ {
+ // When using HTTPS directly (not forwarded), HSTS should be added
+ using var httpsClient = CreateHttpsClient();
+
+ var response = await httpsClient.GetAsync("/js/app.constants.js");
+ response.EnsureSuccessStatusCode();
+
+ Assert.That(response.Headers.Contains("Strict-Transport-Security"), Is.True);
+ }
+
+ [Test]
+ public async Task Redirect_should_preserve_path()
+ {
+ var response = await SendHttpRequest("/some/path");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ Assert.That(response.Headers.Location?.ToString(), Does.Contain("/some/path"));
+ }
+
+ [Test]
+ public async Task Should_add_hsts_when_forwarded_proto_is_https()
+ {
+ // HSTS should be added when the effective scheme is HTTPS via X-Forwarded-Proto
+ var response = await SendHttpRequest("/js/app.constants.js", forwardedProto: "https");
+
+ response.EnsureSuccessStatusCode();
+ Assert.That(GetHstsHeaderValue(response), Is.Not.Null);
+ }
+
+ [TestCase("HTTPS")]
+ [TestCase("Https")]
+ [TestCase("hTtPs")]
+ public async Task Should_handle_case_insensitive_forwarded_proto(string proto)
+ {
+ var response = await SendHttpRequest("/js/app.constants.js", forwardedProto: proto);
+
+ // Should not redirect - HTTPS proto handling should be case-insensitive
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ }
+
+ [Test]
+ public async Task Should_redirect_when_forwarded_proto_is_http()
+ {
+ var response = await SendHttpRequest("/test", forwardedProto: "http");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ }
+}
diff --git a/src/ServicePulse.Tests/Https/When_ipv6_host_is_used.cs b/src/ServicePulse.Tests/Https/When_ipv6_host_is_used.cs
new file mode 100644
index 0000000000..04290a875d
--- /dev/null
+++ b/src/ServicePulse.Tests/Https/When_ipv6_host_is_used.cs
@@ -0,0 +1,74 @@
+namespace ServicePulse.Tests.Https;
+
+using System.Net;
+using ServicePulse.Tests.Infrastructure;
+
+///
+/// Tests HTTPS redirect behavior with IPv6 addresses.
+/// IPv6 addresses in Host headers must be bracketed: [::1]
+///
+[TestFixture]
+public class When_ipv6_host_is_used : HttpsTestBase
+{
+ [SetUp]
+ public void SetUp()
+ {
+ Factory = TestConfiguration.CreateWithHttpsRedirectEnabled();
+ Client = CreateHttpClient(allowAutoRedirect: false);
+ }
+
+ [Test]
+ public async Task Should_redirect_to_https_with_ipv6_host()
+ {
+ var response = await SendHttpRequest("/test", forwardedHost: "[::1]");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ Assert.That(response.Headers.Location?.ToString(), Does.StartWith("https://[::1]"));
+ }
+
+ [Test]
+ public async Task Should_preserve_ipv6_address_format_in_redirect()
+ {
+ var response = await SendHttpRequest("/test", forwardedHost: "[2001:db8::1]");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ Assert.That(response.Headers.Location?.ToString(), Does.Contain("[2001:db8::1]"));
+ }
+
+ [Test]
+ public async Task Should_handle_ipv6_with_port_in_redirect()
+ {
+ Factory.Dispose();
+ Client.Dispose();
+
+ Factory = TestConfiguration.CreateWithHttpsRedirectEnabled(httpsPort: 8443);
+ Client = CreateHttpClient(allowAutoRedirect: false);
+
+ var response = await SendHttpRequest("/test", forwardedHost: "[::1]");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ // IPv6 with port should be [::1]:8443
+ Assert.That(response.Headers.Location?.ToString(), Does.Contain("[::1]:8443"));
+ }
+
+ [Test]
+ public async Task Should_strip_port_from_ipv6_host_before_redirect()
+ {
+ // Host header with IPv6 and port: [::1]:8080
+ // Should redirect to HTTPS without duplicating port handling
+ var response = await SendHttpRequest("/test", forwardedHost: "[::1]:8080");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ // The redirect should use the HTTPS port (443 by default, omitted)
+ Assert.That(response.Headers.Location?.ToString(), Does.StartWith("https://[::1]"));
+ }
+
+ [Test]
+ public async Task Should_handle_ipv4_mapped_ipv6_in_host()
+ {
+ var response = await SendHttpRequest("/test", forwardedHost: "[::ffff:127.0.0.1]");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect));
+ Assert.That(response.Headers.Location?.ToString(), Does.Contain("[::ffff:127.0.0.1]"));
+ }
+}
diff --git a/src/ServicePulse.Tests/Infrastructure/DebugRequestInfoResponse.cs b/src/ServicePulse.Tests/Infrastructure/DebugRequestInfoResponse.cs
new file mode 100644
index 0000000000..7154db8791
--- /dev/null
+++ b/src/ServicePulse.Tests/Infrastructure/DebugRequestInfoResponse.cs
@@ -0,0 +1,57 @@
+namespace ServicePulse.Tests.Infrastructure;
+
+using System.Text.Json.Serialization;
+
+///
+/// Represents the JSON response from the /debug/request-info endpoint.
+///
+public class DebugRequestInfoResponse
+{
+ [JsonPropertyName("processed")]
+ public ProcessedValues Processed { get; set; } = new();
+
+ [JsonPropertyName("rawHeaders")]
+ public RawHeaders RawHeaders { get; set; } = new();
+
+ [JsonPropertyName("configuration")]
+ public ConfigurationValues Configuration { get; set; } = new();
+}
+
+public class ProcessedValues
+{
+ [JsonPropertyName("scheme")]
+ public string Scheme { get; set; } = string.Empty;
+
+ [JsonPropertyName("host")]
+ public string Host { get; set; } = string.Empty;
+
+ [JsonPropertyName("remoteIpAddress")]
+ public string? RemoteIpAddress { get; set; }
+}
+
+public class RawHeaders
+{
+ [JsonPropertyName("xForwardedFor")]
+ public string XForwardedFor { get; set; } = string.Empty;
+
+ [JsonPropertyName("xForwardedProto")]
+ public string XForwardedProto { get; set; } = string.Empty;
+
+ [JsonPropertyName("xForwardedHost")]
+ public string XForwardedHost { get; set; } = string.Empty;
+}
+
+public class ConfigurationValues
+{
+ [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/ServicePulse.Tests/Infrastructure/FakeRemoteIpAddressMiddleware.cs b/src/ServicePulse.Tests/Infrastructure/FakeRemoteIpAddressMiddleware.cs
new file mode 100644
index 0000000000..b76d1b1314
--- /dev/null
+++ b/src/ServicePulse.Tests/Infrastructure/FakeRemoteIpAddressMiddleware.cs
@@ -0,0 +1,24 @@
+namespace ServicePulse.Tests.Infrastructure;
+
+///
+/// Middleware that injects a simulated remote IP address for testing.
+/// WebApplicationFactory/TestServer doesn't set RemoteIpAddress by default.
+///
+public class FakeRemoteIpAddressMiddleware
+{
+ readonly RequestDelegate next;
+ readonly FakeRemoteIpAddressOptions options;
+
+ public FakeRemoteIpAddressMiddleware(RequestDelegate next, FakeRemoteIpAddressOptions options)
+ {
+ this.next = next;
+ this.options = options;
+ }
+
+ public Task InvokeAsync(HttpContext context)
+ {
+ // Set the RemoteIpAddress - ASP.NET Core allows setting this directly
+ context.Connection.RemoteIpAddress = options.IpAddress;
+ return next(context);
+ }
+}
diff --git a/src/ServicePulse.Tests/Infrastructure/ServicePulseWebApplicationFactory.cs b/src/ServicePulse.Tests/Infrastructure/ServicePulseWebApplicationFactory.cs
new file mode 100644
index 0000000000..d35f353982
--- /dev/null
+++ b/src/ServicePulse.Tests/Infrastructure/ServicePulseWebApplicationFactory.cs
@@ -0,0 +1,120 @@
+namespace ServicePulse.Tests.Infrastructure;
+
+using System.Net;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+
+public class ServicePulseWebApplicationFactory : WebApplicationFactory
+{
+ readonly Dictionary environmentVariables = new();
+ readonly Dictionary originalValues = new();
+ string environment = "Development";
+ string simulatedRemoteIp = "127.0.0.1";
+ bool clearHstsExcludedHosts;
+
+ ///
+ /// Sets an environment variable for this test. The value will be restored after the test.
+ ///
+ public ServicePulseWebApplicationFactory WithEnvironmentVariable(string name, string? value)
+ {
+ environmentVariables[name] = value;
+ return this;
+ }
+
+ ///
+ /// Sets the ASP.NET Core environment (Development, Production, etc.)
+ ///
+ public ServicePulseWebApplicationFactory WithEnvironment(string env)
+ {
+ environment = env;
+ return this;
+ }
+
+ ///
+ /// Sets the simulated remote IP address for incoming requests.
+ ///
+ public ServicePulseWebApplicationFactory WithRemoteIpAddress(string ipAddress)
+ {
+ simulatedRemoteIp = ipAddress;
+ return this;
+ }
+
+ ///
+ /// Clears the HSTS excluded hosts list so HSTS headers are added for localhost.
+ /// By default, ASP.NET Core excludes localhost, 127.0.0.1, and [::1] from HSTS.
+ ///
+ public ServicePulseWebApplicationFactory WithHstsExcludedHostsCleared()
+ {
+ clearHstsExcludedHosts = true;
+ return this;
+ }
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ // Store original values and set new values
+ foreach (var kvp in environmentVariables)
+ {
+ originalValues[kvp.Key] = Environment.GetEnvironmentVariable(kvp.Key);
+ Environment.SetEnvironmentVariable(kvp.Key, kvp.Value);
+ }
+
+ builder.UseEnvironment(environment);
+
+ builder.ConfigureServices(services =>
+ {
+ // Register the simulated IP address options
+ services.AddSingleton(new FakeRemoteIpAddressOptions { IpAddress = IPAddress.Parse(simulatedRemoteIp) });
+
+ // Insert the startup filter at the beginning to ensure our middleware runs first
+ services.Insert(0, ServiceDescriptor.Transient());
+
+ // Clear HSTS excluded hosts if requested (for testing HSTS with localhost)
+ if (clearHstsExcludedHosts)
+ {
+ services.Configure(options =>
+ {
+ options.ExcludedHosts.Clear();
+ });
+ }
+ });
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ // Restore original environment variables
+ foreach (var kvp in originalValues)
+ {
+ Environment.SetEnvironmentVariable(kvp.Key, kvp.Value);
+ }
+ }
+
+ base.Dispose(disposing);
+ }
+}
+
+public class FakeRemoteIpAddressOptions
+{
+ public IPAddress IpAddress { get; set; } = IPAddress.Loopback;
+}
+
+public class FakeRemoteIpAddressStartupFilter : IStartupFilter
+{
+ readonly FakeRemoteIpAddressOptions options;
+
+ public FakeRemoteIpAddressStartupFilter(FakeRemoteIpAddressOptions options)
+ {
+ this.options = options;
+ }
+
+ public Action Configure(Action next)
+ {
+ return app =>
+ {
+ app.UseMiddleware(options);
+ next(app);
+ };
+ }
+}
diff --git a/src/ServicePulse.Tests/Infrastructure/TestConfiguration.cs b/src/ServicePulse.Tests/Infrastructure/TestConfiguration.cs
new file mode 100644
index 0000000000..e61178ad42
--- /dev/null
+++ b/src/ServicePulse.Tests/Infrastructure/TestConfiguration.cs
@@ -0,0 +1,159 @@
+namespace ServicePulse.Tests.Infrastructure;
+
+///
+/// Provides common test configuration methods for ServicePulse tests.
+///
+public static class TestConfiguration
+{
+ // Environment variable names
+ public const string ForwardedHeadersEnabled = "SERVICEPULSE_FORWARDEDHEADERS_ENABLED";
+ public const string ForwardedHeadersTrustAllProxies = "SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES";
+ public const string ForwardedHeadersKnownProxies = "SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES";
+ public const string ForwardedHeadersKnownNetworks = "SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS";
+
+ public const string HttpsEnabled = "SERVICEPULSE_HTTPS_ENABLED";
+ public const string HttpsRedirectHttpToHttps = "SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS";
+ public const string HttpsPort = "SERVICEPULSE_HTTPS_PORT";
+ public const string HttpsEnableHsts = "SERVICEPULSE_HTTPS_ENABLEHSTS";
+ public const string HttpsHstsMaxAgeSeconds = "SERVICEPULSE_HTTPS_HSTSMAXAGESECONDS";
+ public const string HttpsHstsIncludeSubDomains = "SERVICEPULSE_HTTPS_HSTSINCLUDESUBDOMAINS";
+
+ ///
+ /// Creates a factory with default forwarded headers configuration (enabled, trust all proxies).
+ ///
+ public static ServicePulseWebApplicationFactory CreateWithForwardedHeadersDefaults()
+ {
+ return new ServicePulseWebApplicationFactory()
+ .WithEnvironmentVariable(ForwardedHeadersEnabled, "true")
+ .WithEnvironmentVariable(ForwardedHeadersTrustAllProxies, "true")
+ .WithEnvironmentVariable(ForwardedHeadersKnownProxies, null)
+ .WithEnvironmentVariable(ForwardedHeadersKnownNetworks, null)
+ .WithEnvironment("Development");
+ }
+
+ ///
+ /// Creates a factory with forwarded headers disabled.
+ ///
+ public static ServicePulseWebApplicationFactory CreateWithForwardedHeadersDisabled()
+ {
+ return new ServicePulseWebApplicationFactory()
+ .WithEnvironmentVariable(ForwardedHeadersEnabled, "false")
+ .WithEnvironmentVariable(ForwardedHeadersTrustAllProxies, null)
+ .WithEnvironmentVariable(ForwardedHeadersKnownProxies, null)
+ .WithEnvironmentVariable(ForwardedHeadersKnownNetworks, null)
+ .WithEnvironment("Development");
+ }
+
+ ///
+ /// Creates a factory with specific known proxies.
+ ///
+ public static ServicePulseWebApplicationFactory CreateWithKnownProxies(params string[] proxies)
+ {
+ return new ServicePulseWebApplicationFactory()
+ .WithEnvironmentVariable(ForwardedHeadersEnabled, "true")
+ .WithEnvironmentVariable(ForwardedHeadersTrustAllProxies, "false")
+ .WithEnvironmentVariable(ForwardedHeadersKnownProxies, string.Join(",", proxies))
+ .WithEnvironmentVariable(ForwardedHeadersKnownNetworks, null)
+ .WithEnvironment("Development");
+ }
+
+ ///
+ /// Creates a factory with specific known networks.
+ ///
+ public static ServicePulseWebApplicationFactory CreateWithKnownNetworks(params string[] networks)
+ {
+ return new ServicePulseWebApplicationFactory()
+ .WithEnvironmentVariable(ForwardedHeadersEnabled, "true")
+ .WithEnvironmentVariable(ForwardedHeadersTrustAllProxies, "false")
+ .WithEnvironmentVariable(ForwardedHeadersKnownProxies, null)
+ .WithEnvironmentVariable(ForwardedHeadersKnownNetworks, string.Join(",", networks))
+ .WithEnvironment("Development");
+ }
+
+ ///
+ /// Creates a factory with both known proxies and networks.
+ ///
+ public static ServicePulseWebApplicationFactory CreateWithKnownProxiesAndNetworks(string[] proxies, string[] networks)
+ {
+ return new ServicePulseWebApplicationFactory()
+ .WithEnvironmentVariable(ForwardedHeadersEnabled, "true")
+ .WithEnvironmentVariable(ForwardedHeadersTrustAllProxies, "false")
+ .WithEnvironmentVariable(ForwardedHeadersKnownProxies, string.Join(",", proxies))
+ .WithEnvironmentVariable(ForwardedHeadersKnownNetworks, string.Join(",", networks))
+ .WithEnvironment("Development");
+ }
+
+ ///
+ /// Creates a factory with HSTS enabled (requires Production environment).
+ ///
+ public static ServicePulseWebApplicationFactory CreateWithHstsEnabled(
+ int maxAgeSeconds = 31536000,
+ bool includeSubDomains = false)
+ {
+ return new ServicePulseWebApplicationFactory()
+ .WithEnvironmentVariable(HttpsEnableHsts, "true")
+ .WithEnvironmentVariable(HttpsHstsMaxAgeSeconds, maxAgeSeconds.ToString())
+ .WithEnvironmentVariable(HttpsHstsIncludeSubDomains, includeSubDomains.ToString())
+ .WithEnvironmentVariable(HttpsRedirectHttpToHttps, "false")
+ .WithEnvironment("Production") // HSTS requires non-Development
+ .WithHstsExcludedHostsCleared(); // Allow HSTS for localhost in tests
+ }
+
+ ///
+ /// Creates a factory with HTTPS redirect enabled.
+ ///
+ public static ServicePulseWebApplicationFactory CreateWithHttpsRedirectEnabled(int httpsPort = 443)
+ {
+ return new ServicePulseWebApplicationFactory()
+ .WithEnvironmentVariable(HttpsRedirectHttpToHttps, "true")
+ .WithEnvironmentVariable(HttpsPort, httpsPort.ToString())
+ .WithEnvironmentVariable(HttpsEnableHsts, "false")
+ .WithEnvironment("Development");
+ }
+
+ ///
+ /// Creates a factory with all HTTPS features enabled.
+ ///
+ public static ServicePulseWebApplicationFactory CreateWithAllHttpsEnabled(
+ int httpsPort = 443,
+ int maxAgeSeconds = 31536000,
+ bool includeSubDomains = false) =>
+ new ServicePulseWebApplicationFactory()
+ .WithEnvironmentVariable(HttpsRedirectHttpToHttps, "true")
+ .WithEnvironmentVariable(HttpsPort, httpsPort.ToString())
+ .WithEnvironmentVariable(HttpsEnableHsts, "true")
+ .WithEnvironmentVariable(HttpsHstsMaxAgeSeconds, maxAgeSeconds.ToString())
+ .WithEnvironmentVariable(HttpsHstsIncludeSubDomains, includeSubDomains.ToString())
+ .WithEnvironment("Production") // HSTS requires non-Development
+ .WithHstsExcludedHostsCleared(); // Allow HSTS for localhost in tests
+
+ ///
+ /// Creates a factory with HTTPS disabled.
+ ///
+ public static ServicePulseWebApplicationFactory CreateWithHttpsDisabled()
+ {
+ return new ServicePulseWebApplicationFactory()
+ .WithEnvironmentVariable(HttpsEnabled, "false")
+ .WithEnvironmentVariable(HttpsRedirectHttpToHttps, "false")
+ .WithEnvironmentVariable(HttpsPort, null)
+ .WithEnvironmentVariable(HttpsEnableHsts, "false")
+ .WithEnvironmentVariable(HttpsHstsMaxAgeSeconds, null)
+ .WithEnvironmentVariable(HttpsHstsIncludeSubDomains, null)
+ .WithEnvironment("Development");
+ }
+
+ ///
+ /// Creates a factory with forwarded headers and HTTPS features (reverse proxy scenario).
+ ///
+ public static ServicePulseWebApplicationFactory CreateWithForwardedHeadersAndHttps(int httpsPort = 443)
+ {
+ return new ServicePulseWebApplicationFactory()
+ .WithEnvironmentVariable(ForwardedHeadersEnabled, "true")
+ .WithEnvironmentVariable(ForwardedHeadersTrustAllProxies, "true")
+ .WithEnvironmentVariable(HttpsRedirectHttpToHttps, "true")
+ .WithEnvironmentVariable(HttpsPort, httpsPort.ToString())
+ .WithEnvironmentVariable(HttpsEnableHsts, "true")
+ .WithEnvironment("Production") // HSTS requires non-Development
+ .WithHstsExcludedHostsCleared(); // Allow HSTS for localhost in tests
+ }
+}
diff --git a/src/ServicePulse.Tests/Properties/launchSettings.json b/src/ServicePulse.Tests/Properties/launchSettings.json
new file mode 100644
index 0000000000..2e42b012d9
--- /dev/null
+++ b/src/ServicePulse.Tests/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "ServicePulse.Tests": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:57663;http://localhost:57664"
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ServicePulse.Tests/ServicePulse.Tests.csproj b/src/ServicePulse.Tests/ServicePulse.Tests.csproj
new file mode 100644
index 0000000000..27a074a049
--- /dev/null
+++ b/src/ServicePulse.Tests/ServicePulse.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ServicePulse.sln b/src/ServicePulse.sln
index 2d35b6f089..1114063682 100644
--- a/src/ServicePulse.sln
+++ b/src/ServicePulse.sln
@@ -36,6 +36,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServicePulse", "ServicePulse\ServicePulse.csproj", "{084808CF-4B93-4097-BFA1-2604AA7B4594}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServicePulse.Tests", "ServicePulse.Tests\ServicePulse.Tests.csproj", "{9B75F526-937E-4B25-A9F6-2862129993EB}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -88,6 +90,10 @@ Global
{084808CF-4B93-4097-BFA1-2604AA7B4594}.Debug|Any CPU.Build.0 = Debug|Any CPU
{084808CF-4B93-4097-BFA1-2604AA7B4594}.Release|Any CPU.ActiveCfg = Release|Any CPU
{084808CF-4B93-4097-BFA1-2604AA7B4594}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9B75F526-937E-4B25-A9F6-2862129993EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9B75F526-937E-4B25-A9F6-2862129993EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9B75F526-937E-4B25-A9F6-2862129993EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9B75F526-937E-4B25-A9F6-2862129993EB}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/src/ServicePulse/ConstantsFile.cs b/src/ServicePulse/ConstantsFile.cs
index 9ab55df879..fc76fb95da 100644
--- a/src/ServicePulse/ConstantsFile.cs
+++ b/src/ServicePulse/ConstantsFile.cs
@@ -18,8 +18,23 @@ public static string GetContent(Settings settings)
}
else
{
- serviceControlUrl = settings.ServiceControlUri.ToString();
- monitoringUrl = settings.MonitoringUri?.ToString() ?? "!";
+ // When HTTPS is enabled, upgrade backend URLs to HTTPS
+ var scUri = settings.HttpsEnabled
+ ? UpgradeToHttps(settings.ServiceControlUri)
+ : settings.ServiceControlUri;
+ serviceControlUrl = scUri.ToString();
+
+ if (settings.MonitoringUri != null)
+ {
+ var mUri = settings.HttpsEnabled
+ ? UpgradeToHttps(settings.MonitoringUri)
+ : settings.MonitoringUri;
+ monitoringUrl = mUri.ToString();
+ }
+ else
+ {
+ monitoringUrl = "!";
+ }
}
var constantsFile = $$"""
@@ -51,4 +66,20 @@ static string GetVersionInformation()
return majorMinorPatch;
}
+
+ static Uri UpgradeToHttps(Uri uri)
+ {
+ if (uri.Scheme == Uri.UriSchemeHttps)
+ {
+ return uri;
+ }
+
+ var builder = new UriBuilder(uri)
+ {
+ Scheme = Uri.UriSchemeHttps,
+ Port = uri.IsDefaultPort ? -1 : uri.Port
+ };
+
+ return builder.Uri;
+ }
}
diff --git a/src/ServicePulse/LoggerUtil.cs b/src/ServicePulse/LoggerUtil.cs
new file mode 100644
index 0000000000..48e23d8020
--- /dev/null
+++ b/src/ServicePulse/LoggerUtil.cs
@@ -0,0 +1,26 @@
+namespace ServicePulse;
+
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Logging;
+
+static class LoggerUtil
+{
+ static readonly ConcurrentDictionary factories = new();
+
+ static ILoggerFactory GetOrCreateLoggerFactory(LogLevel level)
+ {
+ if (!factories.TryGetValue(level, out var factory))
+ {
+ factory = LoggerFactory.Create(builder => builder.AddConsole().SetMinimumLevel(level));
+ factories[level] = factory;
+ }
+
+ return factory;
+ }
+
+ public static ILogger CreateStaticLogger(LogLevel level = LogLevel.Information)
+ {
+ var factory = GetOrCreateLoggerFactory(level);
+ return factory.CreateLogger();
+ }
+}
diff --git a/src/ServicePulse/Program.cs b/src/ServicePulse/Program.cs
index a4c304be50..9b4ca85024 100644
--- a/src/ServicePulse/Program.cs
+++ b/src/ServicePulse/Program.cs
@@ -6,6 +6,9 @@
var settings = Settings.GetFromEnvironmentVariables();
+// Configure Kestrel for HTTPS if enabled
+builder.ConfigureHttps(settings);
+
if (settings.EnableReverseProxy)
{
var (routes, clusters) = ReverseProxy.GetConfiguration(settings);
@@ -14,6 +17,12 @@
var app = builder.Build();
+// Forwarded headers must be first in the pipeline for correct scheme/host detection
+app.UseForwardedHeaders(settings);
+
+// HTTPS middleware (HSTS and redirect)
+app.UseHttpsConfiguration(settings);
+
var manifestEmbeddedFileProvider = new ManifestEmbeddedFileProvider(typeof(Program).Assembly, "wwwroot");
var fileProvider = new CompositeFileProvider(builder.Environment.WebRootFileProvider, manifestEmbeddedFileProvider);
@@ -37,3 +46,8 @@
});
app.Run();
+
+// Make Program class accessible for WebApplicationFactory in tests
+#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
+public partial class Program { }
+#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member
diff --git a/src/ServicePulse/ReverseProxy.cs b/src/ServicePulse/ReverseProxy.cs
index 90ccc591d9..61e11c833f 100644
--- a/src/ServicePulse/ReverseProxy.cs
+++ b/src/ServicePulse/ReverseProxy.cs
@@ -10,12 +10,17 @@ public static (List routes, List clusters) GetConfig
var routes = new List();
var clusters = new List();
+ // When HTTPS is enabled on ServicePulse, assume ServiceControl also uses HTTPS
+ var serviceControlUri = settings.HttpsEnabled
+ ? UpgradeToHttps(settings.ServiceControlUri)
+ : settings.ServiceControlUri;
+
var serviceControlInstance = new ClusterConfig
{
ClusterId = "serviceControlInstance",
Destinations = new Dictionary
{
- { "instance", new DestinationConfig { Address = settings.ServiceControlUri.ToString() } }
+ { "instance", new DestinationConfig { Address = serviceControlUri.ToString() } }
}
};
var serviceControlRoute = new RouteConfig
@@ -33,12 +38,17 @@ public static (List routes, List clusters) GetConfig
if (settings.MonitoringUri != null)
{
+ // When HTTPS is enabled on ServicePulse, assume Monitoring also uses HTTPS
+ var monitoringUri = settings.HttpsEnabled
+ ? UpgradeToHttps(settings.MonitoringUri)
+ : settings.MonitoringUri;
+
var monitoringInstance = new ClusterConfig
{
ClusterId = "monitoringInstance",
Destinations = new Dictionary
{
- { "instance", new DestinationConfig { Address = settings.MonitoringUri.ToString() } }
+ { "instance", new DestinationConfig { Address = monitoringUri.ToString() } }
}
};
@@ -55,4 +65,20 @@ public static (List routes, List clusters) GetConfig
return (routes, clusters);
}
+
+ static Uri UpgradeToHttps(Uri uri)
+ {
+ if (uri.Scheme == Uri.UriSchemeHttps)
+ {
+ return uri;
+ }
+
+ var builder = new UriBuilder(uri)
+ {
+ Scheme = Uri.UriSchemeHttps,
+ Port = uri.IsDefaultPort ? -1 : uri.Port
+ };
+
+ return builder.Uri;
+ }
}
diff --git a/src/ServicePulse/ServicePulse.csproj b/src/ServicePulse/ServicePulse.csproj
index bd2c0b7f43..41f67538e4 100644
--- a/src/ServicePulse/ServicePulse.csproj
+++ b/src/ServicePulse/ServicePulse.csproj
@@ -9,9 +9,13 @@
-
+
+
+
+
+
-
+
diff --git a/src/ServicePulse/Settings.cs b/src/ServicePulse/Settings.cs
index 51717b5cbe..494136b38e 100644
--- a/src/ServicePulse/Settings.cs
+++ b/src/ServicePulse/Settings.cs
@@ -1,9 +1,14 @@
namespace ServicePulse;
+using System.Net;
using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
class Settings
{
+ static readonly ILogger logger = LoggerUtil.CreateStaticLogger();
+
public required Uri ServiceControlUri { get; init; }
public required Uri? MonitoringUri { get; init; }
@@ -14,6 +19,66 @@ class Settings
public required bool EnableReverseProxy { get; init; }
+ ///
+ /// Indicates whether forwarded headers processing for reverse proxy scenarios is enabled.
+ ///
+ public required bool ForwardedHeadersEnabled { get; init; }
+
+ ///
+ /// Indicates whether all proxies are trusted for forwarded headers.
+ ///
+ public required bool ForwardedHeadersTrustAllProxies { get; init; }
+
+ ///
+ /// List of known proxy IP addresses for forwarded headers.
+ ///
+ public required IReadOnlyList ForwardedHeadersKnownProxies { get; init; }
+
+ ///
+ /// List of known networks for forwarded headers.
+ ///
+ public required IReadOnlyList ForwardedHeadersKnownNetworks { get; init; }
+
+ ///
+ /// Indicates whether HTTPS is enabled.
+ ///
+ public required bool HttpsEnabled { get; init; }
+
+ ///
+ /// Path to the HTTPS certificate file.
+ ///
+ public required string? HttpsCertificatePath { get; init; }
+
+ ///
+ /// Password for the HTTPS certificate.
+ ///
+ public required string? HttpsCertificatePassword { get; init; }
+
+ ///
+ /// Indicates whether HTTP requests should be redirected to HTTPS.
+ ///
+ public required bool HttpsRedirectHttpToHttps { get; init; }
+
+ ///
+ /// The HTTPS port to use.
+ ///
+ public required int? HttpsPort { get; init; }
+
+ ///
+ /// Indicates whether HSTS is enabled.
+ ///
+ public required bool HttpsEnableHsts { get; init; }
+
+ ///
+ /// The max age for HSTS in seconds.
+ ///
+ public required int HttpsHstsMaxAgeSeconds { get; init; }
+
+ ///
+ /// Indicates whether HSTS should include subdomains.
+ ///
+ public required bool HttpsHstsIncludeSubDomains { get; init; }
+
public static Settings GetFromEnvironmentVariables()
{
var serviceControlUrl = Environment.GetEnvironmentVariable("SERVICECONTROL_URL") ?? "http://localhost:33333/api/";
@@ -50,14 +115,148 @@ public static Settings GetFromEnvironmentVariables()
enableReverseProxy = true;
}
- return new Settings
+ var forwardedHeadersEnabled = ParseBool(
+ Environment.GetEnvironmentVariable("SERVICEPULSE_FORWARDEDHEADERS_ENABLED"),
+ defaultValue: true);
+
+ var forwardedHeadersTrustAllProxies = ParseBool(
+ Environment.GetEnvironmentVariable("SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES"),
+ defaultValue: true);
+
+ var forwardedHeadersKnownProxies = ParseIpAddresses(
+ Environment.GetEnvironmentVariable("SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES"));
+ var forwardedHeadersKnownNetworks = ParseNetworks(
+ Environment.GetEnvironmentVariable("SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS"));
+
+ // If specific proxies or networks are configured, disable trust all proxies
+ if (forwardedHeadersKnownProxies.Count > 0 || forwardedHeadersKnownNetworks.Count > 0)
+ {
+ forwardedHeadersTrustAllProxies = false;
+ }
+
+ // HTTPS settings
+ var httpsEnabled = ParseBool(
+ Environment.GetEnvironmentVariable("SERVICEPULSE_HTTPS_ENABLED"),
+ defaultValue: false);
+
+ var httpsCertificatePath = Environment.GetEnvironmentVariable("SERVICEPULSE_HTTPS_CERTIFICATEPATH");
+
+ var httpsCertificatePassword = Environment.GetEnvironmentVariable("SERVICEPULSE_HTTPS_CERTIFICATEPASSWORD");
+
+ var httpsRedirectHttpToHttps = ParseBool(
+ Environment.GetEnvironmentVariable("SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS"),
+ defaultValue: false);
+
+ var httpsPort = ParseNullableInt(
+ Environment.GetEnvironmentVariable("SERVICEPULSE_HTTPS_PORT"));
+
+ var httpsEnableHsts = ParseBool(
+ Environment.GetEnvironmentVariable("SERVICEPULSE_HTTPS_ENABLEHSTS"),
+ defaultValue: false);
+
+ var httpsHstsMaxAgeSeconds = ParseInt(
+ Environment.GetEnvironmentVariable("SERVICEPULSE_HTTPS_HSTSMAXAGESECONDS"),
+ defaultValue: 31536000); // 1 year
+
+ var httpsHstsIncludeSubDomains = ParseBool(
+ Environment.GetEnvironmentVariable("SERVICEPULSE_HTTPS_HSTSINCLUDESUBDOMAINS"),
+ defaultValue: false);
+
+ var settings = new Settings
{
ServiceControlUri = serviceControlUri,
MonitoringUri = monitoringUri,
DefaultRoute = defaultRoute,
ShowPendingRetry = showPendingRetry,
- EnableReverseProxy = enableReverseProxy
+ EnableReverseProxy = enableReverseProxy,
+ ForwardedHeadersEnabled = forwardedHeadersEnabled,
+ ForwardedHeadersTrustAllProxies = forwardedHeadersTrustAllProxies,
+ ForwardedHeadersKnownProxies = forwardedHeadersKnownProxies,
+ ForwardedHeadersKnownNetworks = forwardedHeadersKnownNetworks,
+ HttpsEnabled = httpsEnabled,
+ HttpsCertificatePath = httpsCertificatePath,
+ HttpsCertificatePassword = httpsCertificatePassword,
+ HttpsRedirectHttpToHttps = httpsRedirectHttpToHttps,
+ HttpsPort = httpsPort,
+ HttpsEnableHsts = httpsEnableHsts,
+ HttpsHstsMaxAgeSeconds = httpsHstsMaxAgeSeconds,
+ HttpsHstsIncludeSubDomains = httpsHstsIncludeSubDomains
};
+
+ settings.LogForwardedHeadersConfiguration();
+ settings.ValidateHttpsCertificateConfiguration();
+ settings.LogHttpsConfiguration();
+
+ return settings;
+ }
+
+ static bool ParseBool(string? value, bool defaultValue)
+ {
+ if (bool.TryParse(value, out var result))
+ {
+ return result;
+ }
+ return defaultValue;
+ }
+
+ static int ParseInt(string? value, int defaultValue)
+ {
+ if (int.TryParse(value, out var result))
+ {
+ return result;
+ }
+ return defaultValue;
+ }
+
+ static int? ParseNullableInt(string? value)
+ {
+ if (int.TryParse(value, out var result))
+ {
+ return result;
+ }
+ return null;
+ }
+
+ static IReadOnlyList ParseIpAddresses(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return [];
+ }
+
+ var addresses = new List();
+ var parts = value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ foreach (var part in parts)
+ {
+ if (IPAddress.TryParse(part, out var address))
+ {
+ addresses.Add(address);
+ }
+ }
+
+ return addresses;
+ }
+
+ static IReadOnlyList ParseNetworks(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return [];
+ }
+
+ var networks = new List();
+ var parts = value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ foreach (var part in parts)
+ {
+ if (IPNetwork.TryParse(part, out var network))
+ {
+ networks.Add(network);
+ }
+ }
+
+ return networks;
}
static string? ParseLegacyMonitoringValue(string? value)
@@ -95,4 +294,130 @@ class MonitoringUrls
{
public string[] Addresses { get; set; } = [];
}
+
+ ///
+ /// Logs the forwarded headers configuration and warns about potential misconfigurations.
+ ///
+ void LogForwardedHeadersConfiguration()
+ {
+ var hasProxyConfig = ForwardedHeadersKnownProxies.Count > 0 || ForwardedHeadersKnownNetworks.Count > 0;
+ var knownProxiesDisplay = ForwardedHeadersKnownProxies.Count > 0
+ ? string.Join(", ", ForwardedHeadersKnownProxies)
+ : "(none)";
+ var knownNetworksDisplay = ForwardedHeadersKnownNetworks.Count > 0
+ ? string.Join(", ", ForwardedHeadersKnownNetworks)
+ : "(none)";
+
+ logger.LogInformation("Forwarded headers settings: Enabled={Enabled}, TrustAllProxies={TrustAllProxies}, KnownProxies={KnownProxies}, KnownNetworks={KnownNetworks}",
+ ForwardedHeadersEnabled, ForwardedHeadersTrustAllProxies, knownProxiesDisplay, knownNetworksDisplay);
+
+ // Warn about potential misconfigurations
+ if (!ForwardedHeadersEnabled && hasProxyConfig)
+ {
+ logger.LogWarning("Forwarded headers processing is disabled but proxy configuration is present. SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES and SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS settings will be ignored");
+ }
+
+ if (ForwardedHeadersEnabled && ForwardedHeadersTrustAllProxies)
+ {
+ logger.LogWarning("Forwarded headers is configured to trust all proxies. Any client can spoof X-Forwarded-* headers. Consider configuring SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES or SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS for production environments");
+ }
+
+ if (!ForwardedHeadersEnabled && ForwardedHeadersTrustAllProxies)
+ {
+ logger.LogWarning("Forwarded headers is disabled but TrustAllProxies is true. SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES setting will be ignored");
+ }
+
+ if (ForwardedHeadersEnabled && !ForwardedHeadersTrustAllProxies && !hasProxyConfig)
+ {
+ logger.LogWarning("Forwarded headers is enabled but no trusted proxies are configured. X-Forwarded-* headers will not be processed");
+ }
+
+ if (ForwardedHeadersEnabled && ForwardedHeadersKnownProxies.Count > 0 && ForwardedHeadersKnownNetworks.Count > 0)
+ {
+ logger.LogInformation("Forwarded headers has both KnownProxies and KnownNetworks configured. Both settings will be used to determine trusted proxies");
+ }
+ }
+
+ ///
+ /// Validates the HTTPS certificate configuration when HTTPS is enabled.
+ ///
+ /// Thrown when HTTPS is enabled but the certificate path is not set or the file does not exist.
+ void ValidateHttpsCertificateConfiguration()
+ {
+ if (!HttpsEnabled)
+ {
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(HttpsCertificatePath))
+ {
+ var message = "SERVICEPULSE_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(HttpsCertificatePath))
+ {
+ var message = $"SERVICEPULSE_HTTPS_CERTIFICATEPATH does not exist. Current value: '{HttpsCertificatePath}'";
+ logger.LogCritical(message);
+ throw new InvalidOperationException(message);
+ }
+ }
+
+ ///
+ /// Logs the HTTPS configuration and warns about potential misconfigurations.
+ ///
+ void LogHttpsConfiguration()
+ {
+ var httpsPortDisplay = HttpsPort.HasValue ? HttpsPort.Value.ToString() : "(null)";
+
+ logger.LogInformation("HTTPS settings: Enabled={Enabled}, CertificatePath={CertificatePath}, HasCertificatePassword={HasCertificatePassword}, RedirectHttpToHttps={RedirectHttpToHttps}, HttpsPort={HttpsPort}, EnableHsts={EnableHsts}, HstsMaxAgeSeconds={HstsMaxAgeSeconds}, HstsIncludeSubDomains={HstsIncludeSubDomains}",
+ HttpsEnabled, HttpsCertificatePath, !string.IsNullOrEmpty(HttpsCertificatePassword), HttpsRedirectHttpToHttps, httpsPortDisplay, HttpsEnableHsts, HttpsHstsMaxAgeSeconds, HttpsHstsIncludeSubDomains);
+
+ if (HttpsEnabled && EnableReverseProxy)
+ {
+ logger.LogInformation("HTTPS is enabled with reverse proxy. Backend connections to ServiceControl and Monitoring will be upgraded to HTTPS");
+ }
+
+ if (HttpsEnabled && !EnableReverseProxy)
+ {
+ logger.LogInformation("HTTPS is enabled without reverse proxy. ServiceControl and Monitoring URLs will be upgraded to HTTPS");
+ }
+
+ // Warn about potential misconfigurations
+ if (!HttpsEnabled)
+ {
+ logger.LogWarning("Kestrel HTTPS is disabled. Local communication will not be encrypted unless TLS is terminated by a reverse proxy");
+ }
+
+ if (!HttpsEnabled && (!string.IsNullOrEmpty(HttpsCertificatePath) || !string.IsNullOrEmpty(HttpsCertificatePassword)))
+ {
+ logger.LogWarning("HTTPS is disabled but certificate settings are provided. SERVICEPULSE_HTTPS_CERTIFICATEPATH and SERVICEPULSE_HTTPS_CERTIFICATEPASSWORD will be ignored");
+ }
+
+ if (HttpsRedirectHttpToHttps && !HttpsEnableHsts)
+ {
+ logger.LogWarning("HTTPS redirect is enabled but HSTS is disabled. Consider enabling SERVICEPULSE_HTTPS_ENABLEHSTS for better security");
+ }
+
+ if (!HttpsEnabled && HttpsEnableHsts)
+ {
+ logger.LogWarning("HSTS is enabled but Kestrel HTTPS is disabled. HSTS headers will only be effective if TLS is terminated by a reverse proxy");
+ }
+
+ if (!HttpsEnabled && HttpsRedirectHttpToHttps)
+ {
+ logger.LogWarning("HTTPS redirect is enabled but Kestrel HTTPS is disabled. Redirect will only work if TLS is terminated by a reverse proxy");
+ }
+
+ if (HttpsPort.HasValue && !HttpsRedirectHttpToHttps)
+ {
+ logger.LogWarning("SERVICEPULSE_HTTPS_PORT is configured but HTTPS redirect is disabled. The port setting will be ignored");
+ }
+
+ if (HttpsRedirectHttpToHttps && !HttpsPort.HasValue)
+ {
+ logger.LogInformation("SERVICEPULSE_HTTPS_PORT is not configured. HTTPS redirect will be ignored");
+ }
+ }
}
diff --git a/src/ServicePulse/WebApplicationBuilderExtensions.cs b/src/ServicePulse/WebApplicationBuilderExtensions.cs
new file mode 100644
index 0000000000..60bcd32cba
--- /dev/null
+++ b/src/ServicePulse/WebApplicationBuilderExtensions.cs
@@ -0,0 +1,72 @@
+namespace ServicePulse;
+
+using System.Security.Cryptography.X509Certificates;
+
+static class WebApplicationBuilderExtensions
+{
+ public static void ConfigureHttps(this WebApplicationBuilder builder, Settings settings)
+ {
+ // EnableHsts is disabled by default
+ // Hsts is automatically disabled in Development environments
+ if (settings.HttpsEnableHsts)
+ {
+ builder.Services.AddHsts(options =>
+ {
+ options.MaxAge = TimeSpan.FromSeconds(settings.HttpsHstsMaxAgeSeconds);
+ options.IncludeSubDomains = settings.HttpsHstsIncludeSubDomains;
+ });
+ }
+
+ // RedirectHttpToHttps is disabled by default. HttpsPort is null by default.
+ if (settings.HttpsRedirectHttpToHttps && settings.HttpsPort.HasValue)
+ {
+ builder.Services.AddHttpsRedirection(options =>
+ {
+ options.HttpsPort = settings.HttpsPort.Value;
+ });
+ }
+
+ // Kestrel HTTPS is disabled by default
+ if (settings.HttpsEnabled)
+ {
+ var certificate = LoadCertificate(settings);
+
+ builder.WebHost.ConfigureKestrel(serverOptions =>
+ {
+ serverOptions.ConfigureHttpsDefaults(httpsOptions =>
+ {
+ httpsOptions.ServerCertificate = certificate;
+ });
+ });
+
+ // Change URL scheme to HTTPS when HTTPS is enabled.
+ // If ASPNETCORE_URLS is set, convert http:// to https://.
+ // Otherwise, use a default HTTPS URL.
+ var configuredUrls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS");
+ if (!string.IsNullOrEmpty(configuredUrls))
+ {
+ var httpsUrls = configuredUrls.Replace("http://", "https://");
+ builder.WebHost.UseUrls(httpsUrls);
+ }
+ else
+ {
+ builder.WebHost.UseUrls("https://*:5291");
+ }
+ }
+ }
+
+ static X509Certificate2 LoadCertificate(Settings settings)
+ {
+ var certPath = settings.HttpsCertificatePath
+ ?? throw new InvalidOperationException("HTTPS is enabled but HTTPS_CERTIFICATEPATH is not set.");
+
+ if (!File.Exists(certPath))
+ {
+ throw new FileNotFoundException($"Certificate file not found: {certPath}");
+ }
+
+ return string.IsNullOrEmpty(settings.HttpsCertificatePassword)
+ ? new X509Certificate2(certPath)
+ : new X509Certificate2(certPath, settings.HttpsCertificatePassword);
+ }
+}
diff --git a/src/ServicePulse/WebApplicationExtensions.cs b/src/ServicePulse/WebApplicationExtensions.cs
new file mode 100644
index 0000000000..3f2f76da01
--- /dev/null
+++ b/src/ServicePulse/WebApplicationExtensions.cs
@@ -0,0 +1,101 @@
+namespace ServicePulse;
+
+using Microsoft.AspNetCore.HttpOverrides;
+
+static class WebApplicationExtensions
+{
+ public static void UseForwardedHeaders(this WebApplication app, Settings 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.ForwardedHeadersKnownProxies.Select(p => p.ToString()).ToArray();
+ var knownNetworks = settings.ForwardedHeadersKnownNetworks.Select(n => $"{n.Prefix}/{n.PrefixLength}").ToArray();
+
+ return new
+ {
+ processed = new { scheme, host, remoteIpAddress },
+ rawHeaders = new { xForwardedFor, xForwardedProto, xForwardedHost },
+ configuration = new
+ {
+ enabled = settings.ForwardedHeadersEnabled,
+ trustAllProxies = settings.ForwardedHeadersTrustAllProxies,
+ knownProxies,
+ knownNetworks
+ }
+ };
+ });
+ }
+
+ // Forwarded headers processing is enabled by default
+ if (!settings.ForwardedHeadersEnabled)
+ {
+ return;
+ }
+
+ // Attempt to process all forwarded headers
+ var options = new ForwardedHeadersOptions
+ {
+ ForwardedHeaders = ForwardedHeaders.All
+ };
+
+ // Clear default loopback-only restrictions
+ options.KnownProxies.Clear();
+ options.KnownNetworks.Clear();
+
+ // Enabled by default
+ if (settings.ForwardedHeadersTrustAllProxies)
+ {
+ // Trust all proxies: remove hop limit
+ options.ForwardLimit = null;
+ }
+ else
+ {
+ // Only trust explicitly configured proxies and networks
+ foreach (var proxy in settings.ForwardedHeadersKnownProxies)
+ {
+ options.KnownProxies.Add(proxy);
+ }
+
+ foreach (var network in settings.ForwardedHeadersKnownNetworks)
+ {
+ options.KnownNetworks.Add(network);
+ }
+ }
+
+ app.UseForwardedHeaders(options);
+ }
+
+ public static void UseHttpsConfiguration(this WebApplication app, Settings settings)
+ {
+ // EnableHsts is disabled by default
+ // Hsts is automatically disabled in Development environments
+ if (settings.HttpsEnableHsts && !app.Environment.IsDevelopment())
+ {
+ app.UseHsts();
+ }
+
+ // RedirectHttpToHttps is disabled by default
+ if (settings.HttpsRedirectHttpToHttps)
+ {
+ app.UseHttpsRedirection();
+ }
+ }
+}