Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/authentication-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test
set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id
set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/common/v2.0
set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/access_as_user"]
set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_OFFLINEACCESSSCOPEENABLED=
set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA=
set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER=
set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE=
Expand Down Expand Up @@ -282,11 +283,12 @@ curl http://localhost:33633/api/authentication/configuration | json
"enabled": true,
"clientId": "test-client-id",
"audience": "api://servicecontrol-test",
"apiScopes": "[\"api://servicecontrol-test/access_as_user\"]"
"apiScopes": "[\"api://servicecontrol-test/access_as_user\"]",
"scopes": "api://servicecontrol-test/access_as_user openid profile email offline_access"
}
```

The authentication configuration endpoint is accessible without authentication and returns the configuration that clients need to authenticate. The `authority` field is omitted when `ServicePulse.Authority` is not explicitly set (it defaults to the main Authority for ServicePulse clients). The `audience` field is copied from the `ServiceControl/Authentication.Audience` value.
The authentication configuration endpoint is accessible without authentication and returns the configuration that clients need to authenticate. The `authority` field is omitted when `ServicePulse.Authority` is not explicitly set (it defaults to the main Authority for ServicePulse clients). The `audience` field is copied from the `ServiceControl/Authentication.Audience` value. The `apiScopes` field is the raw JSON array as configured. The `scopes` field is the complete, space-separated scope string ServicePulse should request, composed by ServiceControl by parsing the `apiScopes` JSON array and adding the fixed `openid profile email` scopes plus `offline_access` unless `ServiceControl/Authentication.ServicePulse.OfflineAccessScopeEnabled` is set to `false`.

### Scenario 3: Authentication with Invalid Token

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ public static async Task AssertAuthConfigurationResponse(
string expectedAuthority = null,
string expectedAudience = null,
string expectedApiScopes = null,
string expectedScopes = null,
bool expectedRoleBasedAuthorizationEnabled = false)
{
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
Expand Down Expand Up @@ -172,6 +173,17 @@ public static async Task AssertAuthConfigurationResponse(
$"'api_scopes' should be '{expectedApiScopes}'");
}
}

if (expectedScopes != null)
{
using (Assert.EnterMultipleScope())
{
Assert.That(root.TryGetProperty("scopes", out var scopesProperty), Is.True,
"Response should contain 'scopes' property");
Assert.That(scopesProperty.GetString(), Is.EqualTo(expectedScopes),
$"'scopes' should be '{expectedScopes}'");
}
}
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ public OpenIdConnectTestConfiguration WithServicePulseClientId(string clientId)
/// Configures the API scopes that ServicePulse should request.
/// Required on the primary ServiceControl instance when authentication is enabled.
/// </summary>
/// <param name="scopes">Space-separated list of API scopes</param>
/// <param name="scopes">JSON array of API scopes (e.g. <c>["api://my-api/access_as_user"]</c>)</param>
public OpenIdConnectTestConfiguration WithServicePulseApiScopes(string scopes)
{
SetEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_APISCOPES", scopes);
Expand All @@ -159,6 +159,16 @@ public OpenIdConnectTestConfiguration WithServicePulseAuthority(string authority
return this;
}

/// <summary>
/// Configures whether ServicePulse should request the <c>offline_access</c> scope.
/// Default is true. Set to false to simulate an identity provider that disallows the scope.
/// </summary>
public OpenIdConnectTestConfiguration WithServicePulseOfflineAccessScopeEnabled(bool enabled)
{
SetEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_OFFLINEACCESSSCOPEENABLED", enabled.ToString().ToLowerInvariant());
return this;
}

/// <summary>
/// Clears all OpenID Connect environment variables.
/// Called automatically on Dispose.
Expand All @@ -176,6 +186,7 @@ public void ClearConfiguration()
ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_CLIENTID");
ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_APISCOPES");
ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_AUTHORITY");
ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_OFFLINEACCESSSCOPEENABLED");
ClearEnvironmentVariable("AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED");
ClearEnvironmentVariable("VALIDATECONFIG");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ class When_authentication_is_enabled : AcceptanceTest

const string TestAudience = "api://test-audience";
const string TestClientId = "test-client-id";
const string TestApiScopes = "api://test-audience/.default";
const string TestApiScope = "api://test-audience/.default";
// ApiScopes is configured as a JSON array (the format ServicePulse parses)
const string TestApiScopes = "[\"api://test-audience/.default\"]";

[SetUp]
public void ConfigureAuth()
Expand Down Expand Up @@ -75,6 +77,7 @@ await OpenIdConnectAssertions.AssertAuthConfigurationResponse(
expectedClientId: TestClientId,
expectedAudience: TestAudience,
expectedApiScopes: TestApiScopes,
expectedScopes: $"{TestApiScope} openid profile email offline_access",
expectedRoleBasedAuthorizationEnabled: true);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect
{
using System.Net.Http;
using System.Threading.Tasks;
using AcceptanceTesting;
using AcceptanceTesting.OpenIdConnect;
using NServiceBus.AcceptanceTesting;
using NUnit.Framework;

/// <summary>
/// ServicePulse Offline Access Scope Opt-Out
/// When Authentication.ServicePulse.OfflineAccessScopeEnabled is false, the composed scope
/// string returned by the authentication configuration endpoint should omit offline_access,
/// so ServicePulse does not request a scope the identity provider disallows.
/// </summary>
class When_service_pulse_offline_access_scope_is_disabled : AcceptanceTest
{
OpenIdConnectTestConfiguration configuration;

const string TestAuthority = "https://login.example.com/tenant-id/v2.0";
const string TestAudience = "api://test-audience";
const string TestClientId = "test-client-id";
const string TestApiScope = "api://test-audience/.default";
// ApiScopes is configured as a JSON array (the format ServicePulse parses)
const string TestApiScopes = "[\"api://test-audience/.default\"]";

[SetUp]
public void ConfigureAuth() =>
configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary)
.WithConfigurationValidationDisabled()
.WithAuthenticationEnabled()
.WithAuthority(TestAuthority)
.WithAudience(TestAudience)
.WithServicePulseClientId(TestClientId)
.WithServicePulseApiScopes(TestApiScopes)
.WithServicePulseOfflineAccessScopeEnabled(false)
.WithRequireHttpsMetadata(false);

[TearDown]
public void CleanupAuth() => configuration?.Dispose();

[Test]
public async Task Should_omit_offline_access_from_composed_scopes()
{
HttpResponseMessage response = null;

_ = await Define<Context>()
.Done(async ctx =>
{
response = await OpenIdConnectAssertions.SendRequestWithoutAuth(
HttpClient,
HttpMethod.Get,
"/api/authentication/configuration");
return response != null;
})
.Run();

await OpenIdConnectAssertions.AssertAuthConfigurationResponse(
response,
expectedEnabled: true,
expectedClientId: TestClientId,
expectedAudience: TestAudience,
expectedApiScopes: TestApiScopes,
expectedScopes: $"{TestApiScope} openid profile email");
}

class Context : ScenarioContext;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"ServicePulseAuthority": null,
"ServicePulseClientId": null,
"ServicePulseApiScopes": null,
"ServicePulseOfflineAccessScopeEnabled": true,
"ServicePulseScopes": null,
"RolesClaim": "roles",
"RoleBasedAuthorizationEnabled": false
},
Expand Down
77 changes: 74 additions & 3 deletions src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
namespace ServiceControl.Infrastructure;

using System;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using ServiceControl.Configuration;

Expand Down Expand Up @@ -49,6 +50,7 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC
ServicePulseClientId = SettingsReader.Read<string>(rootNamespace, "Authentication.ServicePulse.ClientId");
ServicePulseApiScopes = SettingsReader.Read<string>(rootNamespace, "Authentication.ServicePulse.ApiScopes");
ServicePulseAuthority = SettingsReader.Read<string>(rootNamespace, "Authentication.ServicePulse.Authority");
ServicePulseOfflineAccessScopeEnabled = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.OfflineAccessScopeEnabled", true);
}

if (validateConfiguration)
Expand Down Expand Up @@ -133,11 +135,41 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC
public string ServicePulseClientId { get; }

/// <summary>
/// Space-separated list of API scopes that ServicePulse should request during authentication.
/// JSON array of API scopes that ServicePulse should request during authentication
/// (e.g. <c>["api://my-api/access_as_user"]</c>) — the format ServicePulse parses.
/// Required on the primary ServiceControl instance when authentication is enabled.
/// </summary>
public string ServicePulseApiScopes { get; }

/// <summary>
/// Whether ServicePulse should request the <c>offline_access</c> scope. Defaults to <c>true</c>
/// to preserve existing behaviour. Some identity providers reject authorization requests that
/// include a scope they don't permit, so operators can disable it here rather than have
/// ServicePulse hard-code it into every request.
/// </summary>
public bool ServicePulseOfflineAccessScopeEnabled { get; } = true;

/// <summary>
/// The complete, space-separated scope string ServicePulse should request, composed by parsing the
/// <see cref="ServicePulseApiScopes"/> JSON array and appending the fixed <c>openid profile email</c>
/// scopes required to establish an OIDC session, plus <c>offline_access</c> unless
/// <see cref="ServicePulseOfflineAccessScopeEnabled"/> is <c>false</c>. Returns <c>null</c> when no
/// API scopes are configured (e.g. on non-primary instances).
/// </summary>
public string ServicePulseScopes
{
get
{
if (!TryParseApiScopes(ServicePulseApiScopes, out var apiScopes))
{
return null;
}

var offlineAccessScope = ServicePulseOfflineAccessScopeEnabled ? " offline_access" : "";
return $"{apiScopes} openid profile email{offlineAccessScope}";
}
}

/// <summary>
/// Path within the JWT where the user's role values live. Defaults to the flat <c>roles</c>
/// claim, as emitted by Microsoft Entra ID app roles or Keycloak with a "User Realm Role" mapper.
Expand Down Expand Up @@ -215,6 +247,13 @@ void ValidateRequiredSettings(bool requireServicePulseSettings)
throw new Exception(message);
}

if (!TryParseApiScopes(ServicePulseApiScopes, out _))
{
var message = $"Authentication.ServicePulse.ApiScopes must be a non-empty JSON array of scope strings (e.g. [\"api://my-api/access_as_user\"]). Current value: '{ServicePulseApiScopes}'";
logger.LogCritical(message);
throw new Exception(message);
}

if (ServicePulseAuthority != null && !Uri.TryCreate(ServicePulseAuthority, UriKind.Absolute, out _))
{
var message = $"Authentication.ServicePulse.Authority must be a valid absolute URI. Current value: '{ServicePulseAuthority}'";
Expand All @@ -224,16 +263,48 @@ void ValidateRequiredSettings(bool requireServicePulseSettings)
}
}

/// <summary>
/// Parses the <c>ServicePulse.ApiScopes</c> setting. A JSON array of scope strings, the format
/// ServicePulse expects, into a single space-separated scope string. Returns <c>false</c> for a
/// null/blank, malformed, or empty value.
/// </summary>
static bool TryParseApiScopes(string apiScopes, out string spaceSeparatedScopes)
{
spaceSeparatedScopes = null;

if (string.IsNullOrWhiteSpace(apiScopes))
{
return false;
}

try
{
var scopes = JsonSerializer.Deserialize<string[]>(apiScopes);
if (scopes is null || scopes.Length == 0)
{
return false;
}

spaceSeparatedScopes = string.Join(' ', scopes);
return true;
}
catch (JsonException)
{
return false;
}
}

void LogConfiguration(bool requireServicePulseSettings)
{
var authorityDisplay = Authority ?? "(not configured)";
var audienceDisplay = Audience ?? "(not configured)";
var servicePulseClientIdDisplay = requireServicePulseSettings ? (ServicePulseClientId ?? "(not configured)") : "(n/a)";
var servicePulseAuthorityDisplay = requireServicePulseSettings ? (ServicePulseAuthority ?? "(not configured)") : "(n/a)";
var servicePulseApiScopesDisplay = requireServicePulseSettings ? (ServicePulseApiScopes ?? "(not configured)") : "(n/a)";
var servicePulseOfflineAccessScopeEnabledDisplay = requireServicePulseSettings ? ServicePulseOfflineAccessScopeEnabled.ToString() : "(n/a)";

logger.LogInformation("Authentication settings: Enabled={Enabled}, Authority={Authority}, Audience={Audience}, ValidateIssuer={ValidateIssuer}, ValidateAudience={ValidateAudience}, ValidateLifetime={ValidateLifetime}, ValidateIssuerSigningKey={ValidateIssuerSigningKey}, RequireHttpsMetadata={RequireHttpsMetadata}, RolesClaim={RolesClaim}, SubjectIdClaim={SubjectIdClaim}, SubjectNameClaim={SubjectNameClaim}, ServicePulseClientId={ServicePulseClientId}, ServicePulseAuthority={ServicePulseAuthority}, ServicePulseApiScopes={ServicePulseApiScopes}",
Enabled, authorityDisplay, audienceDisplay, ValidateIssuer, ValidateAudience, ValidateLifetime, ValidateIssuerSigningKey, RequireHttpsMetadata, RolesClaim, SubjectIdClaim, SubjectNameClaim, servicePulseClientIdDisplay, servicePulseAuthorityDisplay, servicePulseApiScopesDisplay);
logger.LogInformation("Authentication settings: Enabled={Enabled}, Authority={Authority}, Audience={Audience}, ValidateIssuer={ValidateIssuer}, ValidateAudience={ValidateAudience}, ValidateLifetime={ValidateLifetime}, ValidateIssuerSigningKey={ValidateIssuerSigningKey}, RequireHttpsMetadata={RequireHttpsMetadata}, RolesClaim={RolesClaim}, SubjectIdClaim={SubjectIdClaim}, SubjectNameClaim={SubjectNameClaim}, ServicePulseClientId={ServicePulseClientId}, ServicePulseAuthority={ServicePulseAuthority}, ServicePulseApiScopes={ServicePulseApiScopes}, ServicePulseOfflineAccessScopeEnabled={ServicePulseOfflineAccessScopeEnabled}",
Enabled, authorityDisplay, audienceDisplay, ValidateIssuer, ValidateAudience, ValidateLifetime, ValidateIssuerSigningKey, RequireHttpsMetadata, RolesClaim, SubjectIdClaim, SubjectNameClaim, servicePulseClientIdDisplay, servicePulseAuthorityDisplay, servicePulseApiScopesDisplay, servicePulseOfflineAccessScopeEnabledDisplay);

// Warn about potential misconfigurations
var hasAuthConfig = !string.IsNullOrWhiteSpace(Authority) || !string.IsNullOrWhiteSpace(Audience);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"ServicePulseAuthority": null,
"ServicePulseClientId": null,
"ServicePulseApiScopes": null,
"ServicePulseOfflineAccessScopeEnabled": true,
"ServicePulseScopes": null,
"RolesClaim": "roles",
"RoleBasedAuthorizationEnabled": false
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"ServicePulseAuthority": null,
"ServicePulseClientId": null,
"ServicePulseApiScopes": null,
"ServicePulseOfflineAccessScopeEnabled": true,
"ServicePulseScopes": null,
"RolesClaim": "roles",
"RoleBasedAuthorizationEnabled": false
},
Expand Down
Loading